From 5d55aaffba2f226ee663d91b01bf76fa31d80775 Mon Sep 17 00:00:00 2001 From: jaeuk Date: Tue, 28 Apr 2026 17:32:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A0=EA=B3=A0=20=EA=B4=80=EB=A0=A8=20api?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/demo/comment/entity/Comment.java | 8 ++ .../demo/comment/entity/CommentStatus.java | 6 + .../demo/global/exception/BaseCode.java | 14 ++- .../global/exception/CustomException.java | 14 +++ .../exception/GlobalExceptionHandler.java | 41 +++--- .../demo/global/exception/ResponseUtil.java | 2 + .../com/example/demo/post/entity/Post.java | 14 +++ .../example/demo/post/entity/PostStatus.java | 6 + .../report/controller/ReportController.java | 56 +++++++++ .../demo/report/dto/ReportCreateRequest.java | 11 ++ .../demo/report/dto/ReportResponse.java | 46 +++++++ .../example/demo/report/entity/Report.java | 57 +++++++++ .../demo/report/entity/ReportStatus.java | 6 + .../demo/report/entity/ReportTargetType.java | 6 + .../report/repository/ReportRepository.java | 23 ++++ .../demo/report/service/ReportService.java | 118 ++++++++++++++++++ 16 files changed, 408 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/example/demo/comment/entity/CommentStatus.java create mode 100644 src/main/java/com/example/demo/global/exception/CustomException.java create mode 100644 src/main/java/com/example/demo/post/entity/PostStatus.java create mode 100644 src/main/java/com/example/demo/report/controller/ReportController.java create mode 100644 src/main/java/com/example/demo/report/dto/ReportCreateRequest.java create mode 100644 src/main/java/com/example/demo/report/dto/ReportResponse.java create mode 100644 src/main/java/com/example/demo/report/entity/Report.java create mode 100644 src/main/java/com/example/demo/report/entity/ReportStatus.java create mode 100644 src/main/java/com/example/demo/report/entity/ReportTargetType.java create mode 100644 src/main/java/com/example/demo/report/repository/ReportRepository.java create mode 100644 src/main/java/com/example/demo/report/service/ReportService.java diff --git a/src/main/java/com/example/demo/comment/entity/Comment.java b/src/main/java/com/example/demo/comment/entity/Comment.java index 80e2639b..5a36bc7c 100644 --- a/src/main/java/com/example/demo/comment/entity/Comment.java +++ b/src/main/java/com/example/demo/comment/entity/Comment.java @@ -27,9 +27,17 @@ public class Comment { private LocalDateTime createdAt; private LocalDateTime updatedAt; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CommentStatus status = CommentStatus.ACTIVE; + public void setUser(User user) { this.user = user; } public void setPost(Post post) { this.post = post; } public void setContent(String c) { this.content = c; } public void setCreatedAt(LocalDateTime t) { this.createdAt = t; } public void setUpdatedAt(LocalDateTime t) { this.updatedAt = t; } + + public void hide() { + this.status = CommentStatus.HIDDEN; + } } diff --git a/src/main/java/com/example/demo/comment/entity/CommentStatus.java b/src/main/java/com/example/demo/comment/entity/CommentStatus.java new file mode 100644 index 00000000..66e111e2 --- /dev/null +++ b/src/main/java/com/example/demo/comment/entity/CommentStatus.java @@ -0,0 +1,6 @@ +package com.example.demo.comment.entity; + +public enum CommentStatus { + ACTIVE, + HIDDEN +} diff --git a/src/main/java/com/example/demo/global/exception/BaseCode.java b/src/main/java/com/example/demo/global/exception/BaseCode.java index 0a77571f..0485afa7 100644 --- a/src/main/java/com/example/demo/global/exception/BaseCode.java +++ b/src/main/java/com/example/demo/global/exception/BaseCode.java @@ -13,6 +13,7 @@ public enum BaseCode { // user USER_CREATE_SUCCESS("2001", "유저 생성 성공"), + USER_NOT_FOUND("4041", "사용자를 찾을 수 없습니다."), // post POST_CREATE_SUCCESS("2010", "게시글 생성 성공"), @@ -23,7 +24,18 @@ public enum BaseCode { // comment COMMENT_CREATE_SUCCESS("2011", "댓글 생성 성공"), COMMENT_UPDATE_SUCCESS("2004", "댓글 수정 성공"), - COMMENT_DELETE_SUCCESS("2005", "댓글 삭제 성공"); + COMMENT_DELETE_SUCCESS("2005", "댓글 삭제 성공"), + COMMENT_NOT_FOUND("4042", "댓글을 찾을 수 없습니다."), + + // report + REPORT_POST_SUCCESS("2020", "게시글 신고 성공"), + REPORT_COMMENT_SUCCESS("2021", "댓글 신고 성공"), + REPORT_RESOLVE_SUCCESS("2022", "신고 처리 완료"), + + REPORT_NOT_FOUND("4043", "신고를 찾을 수 없습니다."), + ALREADY_REPORTED_POST("4003", "이미 신고한 게시글입니다."), + ALREADY_REPORTED_COMMENT("4004", "이미 신고한 댓글입니다."), + ALREADY_RESOLVED_REPORT("4005", "이미 처리 완료된 신고입니다."); private final String code; private final String message; diff --git a/src/main/java/com/example/demo/global/exception/CustomException.java b/src/main/java/com/example/demo/global/exception/CustomException.java new file mode 100644 index 00000000..d32814d7 --- /dev/null +++ b/src/main/java/com/example/demo/global/exception/CustomException.java @@ -0,0 +1,14 @@ +package com.example.demo.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final BaseCode baseCode; + + public CustomException(BaseCode baseCode) { + super(baseCode.getMessage()); + this.baseCode = baseCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/demo/global/exception/GlobalExceptionHandler.java index f0d15cfb..3bba95e5 100644 --- a/src/main/java/com/example/demo/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/demo/global/exception/GlobalExceptionHandler.java @@ -10,11 +10,11 @@ @RestControllerAdvice public class GlobalExceptionHandler { - // 검증 만족 못한 내용들 + // 검증 실패 처리 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity>> handleValidation( - MethodArgumentNotValidException e) { - + MethodArgumentNotValidException e + ) { Map errors = new HashMap<>(); e.getBindingResult().getFieldErrors() @@ -26,27 +26,30 @@ public ResponseEntity>> handleValidation( .body(ResponseUtil.fail(BaseCode.INVALID_REQUEST, errors)); } - // 게시글 없음 - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity>> handleIllegal( - IllegalArgumentException e) { - - if (e.getMessage().contains("게시글 없음")) { - return ResponseEntity.status(404) - .body(ResponseUtil.fail( - BaseCode.POST_NOT_FOUND, - Map.of("postId", -1) - )); - } + // CustomException 처리 + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException( + CustomException e + ) { + return ResponseEntity.badRequest() + .body(ResponseUtil.fail(e.getBaseCode(), null)); + } + // IllegalArgumentException 처리 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegal( + IllegalArgumentException e + ) { return ResponseEntity.badRequest() - .body(ResponseUtil.fail(BaseCode.INVALID_REQUEST, null)); + .body(ResponseUtil.fail(BaseCode.INVALID_REQUEST, e.getMessage())); } - // 기타 예외상황 + // 기타 예외 처리 @ExceptionHandler(Exception.class) - public ResponseEntity> handleException() { + public ResponseEntity> handleException( + Exception e + ) { return ResponseEntity.internalServerError() - .body(ResponseUtil.fail(BaseCode.INVALID_REQUEST, null)); + .body(ResponseUtil.fail(BaseCode.INVALID_REQUEST, e.getMessage())); } } diff --git a/src/main/java/com/example/demo/global/exception/ResponseUtil.java b/src/main/java/com/example/demo/global/exception/ResponseUtil.java index 99744031..f02bf2e4 100644 --- a/src/main/java/com/example/demo/global/exception/ResponseUtil.java +++ b/src/main/java/com/example/demo/global/exception/ResponseUtil.java @@ -2,6 +2,7 @@ public class ResponseUtil { + // 성공 응답 public static ApiResponse success(BaseCode code, T result) { return ApiResponse.builder() .isSuccess(true) @@ -11,6 +12,7 @@ public static ApiResponse success(BaseCode code, T result) { .build(); } + // 실패 응답 public static ApiResponse fail(BaseCode code, T result) { return ApiResponse.builder() .isSuccess(false) diff --git a/src/main/java/com/example/demo/post/entity/Post.java b/src/main/java/com/example/demo/post/entity/Post.java index 657d69da..8b737d61 100644 --- a/src/main/java/com/example/demo/post/entity/Post.java +++ b/src/main/java/com/example/demo/post/entity/Post.java @@ -3,14 +3,19 @@ import com.example.demo.comment.entity.Comment; import com.example.demo.user.entity.User; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; + import java.time.LocalDateTime; import java.util.*; +@Builder @Entity @Getter +@AllArgsConstructor @NoArgsConstructor public class Post { @@ -27,6 +32,11 @@ public class Post { private LocalDateTime updatedAt; private LocalDateTime deletedAt; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private PostStatus status = PostStatus.ACTIVE; + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) private List blocks = new ArrayList<>(); @@ -40,6 +50,10 @@ public class Post { public void setUpdatedAt(LocalDateTime t) { this.updatedAt = t; } public void setDeletedAt(LocalDateTime t) { this.deletedAt = t; } + public void hide() { + this.status = PostStatus.HIDDEN; + } + public void addBlock(PostBlock block) { blocks.add(block); block.setPost(this); diff --git a/src/main/java/com/example/demo/post/entity/PostStatus.java b/src/main/java/com/example/demo/post/entity/PostStatus.java new file mode 100644 index 00000000..d06fc0c9 --- /dev/null +++ b/src/main/java/com/example/demo/post/entity/PostStatus.java @@ -0,0 +1,6 @@ +package com.example.demo.post.entity; + +public enum PostStatus { + ACTIVE, + HIDDEN +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/controller/ReportController.java b/src/main/java/com/example/demo/report/controller/ReportController.java new file mode 100644 index 00000000..8fc4cc55 --- /dev/null +++ b/src/main/java/com/example/demo/report/controller/ReportController.java @@ -0,0 +1,56 @@ +package com.example.demo.report.controller; + +import com.example.demo.global.exception.ApiResponse; +import com.example.demo.global.exception.BaseCode; +import com.example.demo.global.exception.ResponseUtil; +import com.example.demo.report.dto.ReportCreateRequest; +import com.example.demo.report.dto.ReportResponse; +import com.example.demo.report.service.ReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + // 게시글 신고 + @PostMapping("/posts/{postId}/reports") + public ResponseEntity> reportPost( + @PathVariable Long postId, + @RequestBody ReportCreateRequest request + ) { + ReportResponse response = reportService.reportPost(postId, request); + + return ResponseEntity.ok( + ResponseUtil.success(BaseCode.REPORT_POST_SUCCESS, response) + ); + } + + // 댓글 신고 + @PostMapping("/comments/{commentId}/reports") + public ResponseEntity> reportComment( + @PathVariable Long commentId, + @RequestBody ReportCreateRequest request + ) { + ReportResponse response = reportService.reportComment(commentId, request); + + return ResponseEntity.ok( + ResponseUtil.success(BaseCode.REPORT_COMMENT_SUCCESS, response) + ); + } + + // 신고 처리 완료 + @PatchMapping("/reports/{reportId}/resolve") + public ResponseEntity> resolveReport( + @PathVariable Long reportId + ) { + ReportResponse response = reportService.resolveReport(reportId); + + return ResponseEntity.ok( + ResponseUtil.success(BaseCode.REPORT_RESOLVE_SUCCESS, response) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/dto/ReportCreateRequest.java b/src/main/java/com/example/demo/report/dto/ReportCreateRequest.java new file mode 100644 index 00000000..a7381705 --- /dev/null +++ b/src/main/java/com/example/demo/report/dto/ReportCreateRequest.java @@ -0,0 +1,11 @@ +package com.example.demo.report.dto; + +import lombok.Getter; + +@Getter +public class ReportCreateRequest { + + private Long reporterId; + + private String reason; +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/dto/ReportResponse.java b/src/main/java/com/example/demo/report/dto/ReportResponse.java new file mode 100644 index 00000000..2b44b6ab --- /dev/null +++ b/src/main/java/com/example/demo/report/dto/ReportResponse.java @@ -0,0 +1,46 @@ +package com.example.demo.report.dto; + +import com.example.demo.report.entity.Report; +import com.example.demo.report.entity.ReportStatus; +import com.example.demo.report.entity.ReportTargetType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ReportResponse { + + private Long reportId; + private Long reporterId; + private ReportTargetType targetType; + private Long targetId; + private String reason; + private ReportStatus status; + private LocalDateTime createdAt; + private LocalDateTime resolvedAt; + + public static ReportResponse from(Report report) { + Long targetId = null; + + if (report.getTargetType() == ReportTargetType.POST) { + targetId = report.getPost().getId(); + } + + if (report.getTargetType() == ReportTargetType.COMMENT) { + targetId = report.getComment().getId(); + } + + return ReportResponse.builder() + .reportId(report.getId()) + .reporterId(report.getReporter().getId()) + .targetType(report.getTargetType()) + .targetId(targetId) + .reason(report.getReason()) + .status(report.getStatus()) + .createdAt(report.getCreatedAt()) + .resolvedAt(report.getResolvedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/demo/report/entity/Report.java b/src/main/java/com/example/demo/report/entity/Report.java new file mode 100644 index 00000000..a0b3f3ea --- /dev/null +++ b/src/main/java/com/example/demo/report/entity/Report.java @@ -0,0 +1,57 @@ +package com.example.demo.report.entity; + +import com.example.demo.comment.entity.Comment; +import com.example.demo.post.entity.Post; +import com.example.demo.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 신고한 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + // 신고 대상 게시글 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + // 신고 대상 댓글 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportTargetType targetType; + + @Column(nullable = false) + private String reason; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime resolvedAt; + + public void resolve() { + this.status = ReportStatus.RESOLVED; + this.resolvedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/entity/ReportStatus.java b/src/main/java/com/example/demo/report/entity/ReportStatus.java new file mode 100644 index 00000000..86b040cc --- /dev/null +++ b/src/main/java/com/example/demo/report/entity/ReportStatus.java @@ -0,0 +1,6 @@ +package com.example.demo.report.entity; + +public enum ReportStatus { + PENDING, + RESOLVED +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/entity/ReportTargetType.java b/src/main/java/com/example/demo/report/entity/ReportTargetType.java new file mode 100644 index 00000000..c423d7c6 --- /dev/null +++ b/src/main/java/com/example/demo/report/entity/ReportTargetType.java @@ -0,0 +1,6 @@ +package com.example.demo.report.entity; + +public enum ReportTargetType { + POST, + COMMENT +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/repository/ReportRepository.java b/src/main/java/com/example/demo/report/repository/ReportRepository.java new file mode 100644 index 00000000..f1392874 --- /dev/null +++ b/src/main/java/com/example/demo/report/repository/ReportRepository.java @@ -0,0 +1,23 @@ +package com.example.demo.report.repository; + +import com.example.demo.comment.entity.Comment; +import com.example.demo.post.entity.Post; +import com.example.demo.report.entity.Report; +import com.example.demo.report.entity.ReportStatus; +import com.example.demo.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterAndPostAndStatus( + User reporter, + Post post, + ReportStatus status + ); + + boolean existsByReporterAndCommentAndStatus( + User reporter, + Comment comment, + ReportStatus status + ); +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/report/service/ReportService.java b/src/main/java/com/example/demo/report/service/ReportService.java new file mode 100644 index 00000000..a4bdd5f2 --- /dev/null +++ b/src/main/java/com/example/demo/report/service/ReportService.java @@ -0,0 +1,118 @@ +package com.example.demo.report.service; + +import com.example.demo.comment.entity.Comment; +import com.example.demo.comment.repository.CommentRepository; +import com.example.demo.global.exception.BaseCode; +import com.example.demo.global.exception.CustomException; +import com.example.demo.post.entity.Post; +import com.example.demo.post.repository.PostRepository; +import com.example.demo.report.dto.ReportCreateRequest; +import com.example.demo.report.dto.ReportResponse; +import com.example.demo.report.entity.Report; +import com.example.demo.report.entity.ReportStatus; +import com.example.demo.report.entity.ReportTargetType; +import com.example.demo.report.repository.ReportRepository; +import com.example.demo.user.entity.User; +import com.example.demo.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReportService { + + private final ReportRepository reportRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + // 게시글 신고 + public ReportResponse reportPost(Long postId, ReportCreateRequest request) { + User reporter = userRepository.findById(request.getReporterId()) + .orElseThrow(() -> new CustomException(BaseCode.USER_NOT_FOUND)); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(BaseCode.POST_NOT_FOUND)); + + boolean alreadyReported = reportRepository.existsByReporterAndPostAndStatus( + reporter, + post, + ReportStatus.PENDING + ); + + if (alreadyReported) { + throw new CustomException(BaseCode.ALREADY_REPORTED_POST); + } + + Report report = Report.builder() + .reporter(reporter) + .post(post) + .targetType(ReportTargetType.POST) + .reason(request.getReason()) + .status(ReportStatus.PENDING) + .createdAt(LocalDateTime.now()) + .build(); + + Report savedReport = reportRepository.save(report); + + return ReportResponse.from(savedReport); + } + + // 댓글 신고 + public ReportResponse reportComment(Long commentId, ReportCreateRequest request) { + User reporter = userRepository.findById(request.getReporterId()) + .orElseThrow(() -> new CustomException(BaseCode.USER_NOT_FOUND)); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(BaseCode.COMMENT_NOT_FOUND)); + + boolean alreadyReported = reportRepository.existsByReporterAndCommentAndStatus( + reporter, + comment, + ReportStatus.PENDING + ); + + if (alreadyReported) { + throw new CustomException(BaseCode.ALREADY_REPORTED_COMMENT); + } + + Report report = Report.builder() + .reporter(reporter) + .comment(comment) + .targetType(ReportTargetType.COMMENT) + .reason(request.getReason()) + .status(ReportStatus.PENDING) + .createdAt(LocalDateTime.now()) + .build(); + + Report savedReport = reportRepository.save(report); + + return ReportResponse.from(savedReport); + } + + // 신고 처리 완료 + public ReportResponse resolveReport(Long reportId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new CustomException(BaseCode.REPORT_NOT_FOUND)); + + if (report.getStatus() == ReportStatus.RESOLVED) { + throw new CustomException(BaseCode.ALREADY_RESOLVED_REPORT); + } + + report.resolve(); + + if (report.getTargetType() == ReportTargetType.POST) { + report.getPost().hide(); + } + + if (report.getTargetType() == ReportTargetType.COMMENT) { + report.getComment().hide(); + } + + return ReportResponse.from(report); + } +} \ No newline at end of file