From 8fdb31f7f4614e9e1ac9741a0bde9cc52c103896 Mon Sep 17 00:00:00 2001 From: theSnackOverflow Date: Tue, 28 Apr 2026 21:07:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(report):=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC/=EB=8C=93=EA=B8=80=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReportStatus enum 추가 (PENDING/RESOLVED) - Report 엔티티에 status, resolvedBy, resolvedAt 필드 및 resolve() 도메인 메서드 추가 - DB 유니크 제약으로 중복 신고 방지 (reporter+post, reporter+comment) - 자기 자신 신고 차단 로직 추가 - POST /api/v1/posts/{postId}/reports — 게시물 신고 - POST /api/v1/comments/{commentId}/reports — 댓글 신고 - POST /api/v1/reports/{reportId}/resolve — 신고 처리 완료 (PENDING→RESOLVED) - 기존 targetType 기반 단일 엔드포인트 제거 --- .../report/controller/ReportController.java | 14 ++--- .../report/dto/ReportCommentRequest.java | 8 +++ .../report/dto/ReportCreateRequest.java | 17 ------ .../domain/report/dto/ReportPostRequest.java | 8 +++ .../domain/report/dto/ReportResponse.java | 5 ++ .../blog/domain/report/entity/Report.java | 43 ++++++++++++++- .../domain/report/entity/ReportStatus.java | 5 ++ .../report/repository/ReportRepository.java | 4 ++ .../domain/report/service/ReportService.java | 52 ++++++++++++++----- .../blog/global/exception/ErrorCode.java | 9 +++- 10 files changed, 122 insertions(+), 43 deletions(-) create mode 100644 jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCommentRequest.java delete mode 100644 jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCreateRequest.java create mode 100644 jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportPostRequest.java create mode 100644 jihoonkang/src/main/java/com/example/blog/domain/report/entity/ReportStatus.java diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/controller/ReportController.java b/jihoonkang/src/main/java/com/example/blog/domain/report/controller/ReportController.java index 5b2c7bf1..ac816581 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/controller/ReportController.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/controller/ReportController.java @@ -1,12 +1,9 @@ package com.example.blog.domain.report.controller; -import com.example.blog.domain.report.dto.ReportCreateRequest; import com.example.blog.domain.report.dto.ReportResponse; import com.example.blog.domain.report.service.ReportService; import com.example.blog.global.response.ApiResponse; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,13 +15,12 @@ public class ReportController { private final ReportService reportService; - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public ApiResponse create( - @RequestHeader("X-User-Id") Long reporterId, - @RequestBody @Valid ReportCreateRequest request + @PostMapping("/{reportId}/resolve") + public ApiResponse resolve( + @RequestHeader("X-User-Id") Long handlerId, + @PathVariable Long reportId ) { - return ApiResponse.success(reportService.create(reporterId, request)); + return ApiResponse.success(reportService.resolveReport(handlerId, reportId)); } @GetMapping diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCommentRequest.java b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCommentRequest.java new file mode 100644 index 00000000..818bb6f6 --- /dev/null +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCommentRequest.java @@ -0,0 +1,8 @@ +package com.example.blog.domain.report.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReportCommentRequest( + @NotBlank(message = "신고 사유는 필수입니다.") + String reason +) {} diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCreateRequest.java b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCreateRequest.java deleted file mode 100644 index a8aaaa07..00000000 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportCreateRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.blog.domain.report.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record ReportCreateRequest( - - @NotBlank(message = "신고 대상 유형은 필수입니다.") - String targetType, - - @NotNull(message = "신고 대상 ID는 필수입니다.") - Long targetId, - - @NotBlank(message = "신고 사유는 필수입니다.") - String reason - -) {} diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportPostRequest.java b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportPostRequest.java new file mode 100644 index 00000000..03ebfcfe --- /dev/null +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportPostRequest.java @@ -0,0 +1,8 @@ +package com.example.blog.domain.report.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReportPostRequest( + @NotBlank(message = "신고 사유는 필수입니다.") + String reason +) {} diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportResponse.java b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportResponse.java index 79a20dd1..bc2ec7b7 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportResponse.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/dto/ReportResponse.java @@ -1,6 +1,7 @@ package com.example.blog.domain.report.dto; import com.example.blog.domain.report.entity.Report; +import com.example.blog.domain.report.entity.ReportStatus; import java.time.LocalDateTime; @@ -11,6 +12,8 @@ public record ReportResponse( String targetType, Long targetId, String reason, + ReportStatus status, + LocalDateTime resolvedAt, LocalDateTime createdAt ) { @@ -26,6 +29,8 @@ public static ReportResponse from(Report report) { targetType, targetId, report.getReason(), + report.getStatus(), + report.getResolvedAt(), report.getCreatedAt() ); } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/entity/Report.java b/jihoonkang/src/main/java/com/example/blog/domain/report/entity/Report.java index 2f313f00..42eb3745 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/entity/Report.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/entity/Report.java @@ -4,13 +4,23 @@ import com.example.blog.domain.post.entity.Post; import com.example.blog.domain.user.entity.User; import com.example.blog.global.entity.BaseEntity; +import com.example.blog.global.exception.BusinessException; +import com.example.blog.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Entity -@Table(name = "reports") +@Table( + name = "reports", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"reporter_id", "post_id"}), + @UniqueConstraint(columnNames = {"reporter_id", "comment_id"}) + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Report extends BaseEntity { @@ -35,12 +45,41 @@ public class Report extends BaseEntity { @Column(nullable = false) private String reason; - public static Report of(User reporter, Post post, Comment comment, String reason) { + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ReportStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resolved_by") + private User resolvedBy; + + @Column + private LocalDateTime resolvedAt; + + public static Report ofPost(User reporter, Post post, String reason) { Report report = new Report(); report.reporter = reporter; report.post = post; + report.reason = reason; + report.status = ReportStatus.PENDING; + return report; + } + + public static Report ofComment(User reporter, Comment comment, String reason) { + Report report = new Report(); + report.reporter = reporter; report.comment = comment; report.reason = reason; + report.status = ReportStatus.PENDING; return report; } + + public void resolve(User handler) { + if (this.status == ReportStatus.RESOLVED) { + throw new BusinessException(ErrorCode.REPORT_ALREADY_RESOLVED); + } + this.status = ReportStatus.RESOLVED; + this.resolvedBy = handler; + this.resolvedAt = LocalDateTime.now(); + } } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/entity/ReportStatus.java b/jihoonkang/src/main/java/com/example/blog/domain/report/entity/ReportStatus.java new file mode 100644 index 00000000..3f469d39 --- /dev/null +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/entity/ReportStatus.java @@ -0,0 +1,5 @@ +package com.example.blog.domain.report.entity; + +public enum ReportStatus { + PENDING, RESOLVED +} diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/repository/ReportRepository.java b/jihoonkang/src/main/java/com/example/blog/domain/report/repository/ReportRepository.java index 14887600..787be8aa 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/repository/ReportRepository.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/repository/ReportRepository.java @@ -4,4 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ReportRepository extends JpaRepository { + + boolean existsByReporter_IdAndPost_Id(Long reporterId, Long postId); + + boolean existsByReporter_IdAndComment_Id(Long reporterId, Long commentId); } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/report/service/ReportService.java b/jihoonkang/src/main/java/com/example/blog/domain/report/service/ReportService.java index 9b8ca855..41f3648e 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/report/service/ReportService.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/report/service/ReportService.java @@ -4,7 +4,8 @@ import com.example.blog.domain.comment.repository.CommentRepository; import com.example.blog.domain.post.entity.Post; import com.example.blog.domain.post.repository.PostRepository; -import com.example.blog.domain.report.dto.ReportCreateRequest; +import com.example.blog.domain.report.dto.ReportCommentRequest; +import com.example.blog.domain.report.dto.ReportPostRequest; import com.example.blog.domain.report.dto.ReportResponse; import com.example.blog.domain.report.entity.Report; import com.example.blog.domain.report.repository.ReportRepository; @@ -29,27 +30,50 @@ public class ReportService { private final PostRepository postRepository; private final CommentRepository commentRepository; - public ReportResponse create(Long reporterId, ReportCreateRequest request) { + public ReportResponse reportPost(Long reporterId, Long postId, ReportPostRequest request) { User reporter = userRepository.findById(reporterId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorCode.POST_NOT_FOUND)); - Post post = null; - Comment comment = null; - - if ("POST".equalsIgnoreCase(request.targetType())) { - post = postRepository.findById(request.targetId()) - .orElseThrow(() -> new NotFoundException(ErrorCode.POST_NOT_FOUND)); - } else if ("COMMENT".equalsIgnoreCase(request.targetType())) { - comment = commentRepository.findById(request.targetId()) - .orElseThrow(() -> new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); - } else { - throw new BusinessException(ErrorCode.INVALID_REPORT_TARGET); + if (post.getUser().getId().equals(reporterId)) { + throw new BusinessException(ErrorCode.CANNOT_REPORT_SELF); } + if (reportRepository.existsByReporter_IdAndPost_Id(reporterId, postId)) { + throw new BusinessException(ErrorCode.ALREADY_REPORTED); + } + + Report report = Report.ofPost(reporter, post, request.reason()); + return ReportResponse.from(reportRepository.save(report)); + } + + public ReportResponse reportComment(Long reporterId, Long commentId, ReportCommentRequest request) { + User reporter = userRepository.findById(reporterId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); - Report report = Report.of(reporter, post, comment, request.reason()); + if (comment.getUser().getId().equals(reporterId)) { + throw new BusinessException(ErrorCode.CANNOT_REPORT_SELF); + } + if (reportRepository.existsByReporter_IdAndComment_Id(reporterId, commentId)) { + throw new BusinessException(ErrorCode.ALREADY_REPORTED); + } + + Report report = Report.ofComment(reporter, comment, request.reason()); return ReportResponse.from(reportRepository.save(report)); } + public ReportResponse resolveReport(Long handlerId, Long reportId) { + User handler = userRepository.findById(handlerId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.INVALID_REPORT_TARGET)); + + report.resolve(handler); + return ReportResponse.from(report); + } + @Transactional(readOnly = true) public List findAll() { return reportRepository.findAll().stream() diff --git a/jihoonkang/src/main/java/com/example/blog/global/exception/ErrorCode.java b/jihoonkang/src/main/java/com/example/blog/global/exception/ErrorCode.java index 061aaff1..a3de2104 100644 --- a/jihoonkang/src/main/java/com/example/blog/global/exception/ErrorCode.java +++ b/jihoonkang/src/main/java/com/example/blog/global/exception/ErrorCode.java @@ -17,7 +17,14 @@ public enum ErrorCode { FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "A001", "접근 권한이 없습니다."), - INVALID_REPORT_TARGET(HttpStatus.BAD_REQUEST, "R001", "유효하지 않은 신고 대상입니다."); + INVALID_REPORT_TARGET(HttpStatus.BAD_REQUEST, "R001", "유효하지 않은 신고 대상입니다."), + ALREADY_REPORTED(HttpStatus.CONFLICT, "R002", "이미 신고한 대상입니다."), + CANNOT_REPORT_SELF(HttpStatus.BAD_REQUEST, "R003", "자신의 게시물/댓글은 신고할 수 없습니다."), + REPORT_ALREADY_RESOLVED(HttpStatus.CONFLICT, "R004", "이미 처리된 신고입니다."), + + POST_ALREADY_HIDDEN(HttpStatus.CONFLICT, "P002", "이미 숨김 처리된 게시물입니다."), + + INVALID_PARENT_COMMENT(HttpStatus.BAD_REQUEST, "C002", "유효하지 않은 부모 댓글입니다."); private final HttpStatus httpStatus; private final String code; From efd65f5bc705afc635e9ba93d7490adbeb9a5021 Mon Sep 17 00:00:00 2001 From: theSnackOverflow Date: Tue, 28 Apr 2026 21:07:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(post):=20=EA=B2=8C=EC=8B=9C=EB=AC=BC?= =?UTF-8?q?=20=EC=88=A8=EA=B9=80=20=EC=95=A1=EC=85=98=20API=20=EB=B0=8F=20?= =?UTF-8?q?PostStatus=20enum=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PostStatus enum 추가 (PUBLISHED/HIDDEN) - Post 엔티티의 status 필드를 문자열에서 PostStatus enum으로 교체 - hide() 도메인 메서드 추가 (이미 HIDDEN이면 409 반환) - POST /api/v1/posts/{postId}/hide — 게시물 숨김 (작성자 전용) - 게시물 신고 엔드포인트 PostController로 통합 --- .../post/controller/PostController.java | 22 ++++++++++++++++ .../blog/domain/post/dto/PostResponse.java | 2 +- .../example/blog/domain/post/entity/Post.java | 18 ++++++++++--- .../blog/domain/post/entity/PostStatus.java | 5 ++++ .../post/repository/PostRepository.java | 3 ++- .../blog/domain/post/service/PostService.java | 26 ++++++++++++++++--- 6 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 jihoonkang/src/main/java/com/example/blog/domain/post/entity/PostStatus.java diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/controller/PostController.java b/jihoonkang/src/main/java/com/example/blog/domain/post/controller/PostController.java index be6fd78c..83279c02 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/post/controller/PostController.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/controller/PostController.java @@ -5,6 +5,9 @@ import com.example.blog.domain.post.dto.PostResponse; import com.example.blog.domain.post.dto.PostUpdateRequest; import com.example.blog.domain.post.service.PostService; +import com.example.blog.domain.report.dto.ReportPostRequest; +import com.example.blog.domain.report.dto.ReportResponse; +import com.example.blog.domain.report.service.ReportService; import com.example.blog.global.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -17,6 +20,7 @@ public class PostController { private final PostService postService; + private final ReportService reportService; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -58,4 +62,22 @@ public void delete( ) { postService.delete(userId, postId); } + + @PostMapping("/{postId}/hide") + public ApiResponse hide( + @RequestHeader("X-User-Id") Long userId, + @PathVariable Long postId + ) { + return ApiResponse.success(postService.hidePost(userId, postId)); + } + + @PostMapping("/{postId}/reports") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse reportPost( + @RequestHeader("X-User-Id") Long reporterId, + @PathVariable Long postId, + @RequestBody @Valid ReportPostRequest request + ) { + return ApiResponse.success(reportService.reportPost(reporterId, postId, request)); + } } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/dto/PostResponse.java b/jihoonkang/src/main/java/com/example/blog/domain/post/dto/PostResponse.java index 868cb32c..701dea8a 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/post/dto/PostResponse.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/dto/PostResponse.java @@ -22,7 +22,7 @@ public static PostResponse from(Post post) { post.getUser().getUsername(), post.getTitle(), post.getContent(), - post.getStatus(), + post.getStatus().name(), post.getCreatedAt(), post.getUpdatedAt() ); diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/entity/Post.java b/jihoonkang/src/main/java/com/example/blog/domain/post/entity/Post.java index 81dd3057..a668e88d 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/post/entity/Post.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/entity/Post.java @@ -3,6 +3,8 @@ import com.example.blog.domain.comment.entity.Comment; import com.example.blog.domain.user.entity.User; import com.example.blog.global.entity.BaseEntity; +import com.example.blog.global.exception.BusinessException; +import com.example.blog.global.exception.ErrorCode; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -32,22 +34,23 @@ public class Post extends BaseEntity { @Column(columnDefinition = "TEXT") private String content; + @Enumerated(EnumType.STRING) @Column(length = 20) - private String status; + private PostStatus status; @OneToMany(mappedBy = "post") private List comments = new ArrayList<>(); - public static Post of(User user, String title, String content, String status) { + public static Post of(User user, String title, String content, PostStatus status) { Post post = new Post(); post.user = user; post.title = title; post.content = content; - post.status = status != null ? status : "PUBLISHED"; + post.status = status != null ? status : PostStatus.PUBLISHED; return post; } - public void update(String title, String content, String status) { + public void update(String title, String content, PostStatus status) { if (title != null) { this.title = title; } @@ -58,4 +61,11 @@ public void update(String title, String content, String status) { this.status = status; } } + + public void hide() { + if (this.status == PostStatus.HIDDEN) { + throw new BusinessException(ErrorCode.POST_ALREADY_HIDDEN); + } + this.status = PostStatus.HIDDEN; + } } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/entity/PostStatus.java b/jihoonkang/src/main/java/com/example/blog/domain/post/entity/PostStatus.java new file mode 100644 index 00000000..245596b4 --- /dev/null +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/entity/PostStatus.java @@ -0,0 +1,5 @@ +package com.example.blog.domain.post.entity; + +public enum PostStatus { + PUBLISHED, HIDDEN +} diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/repository/PostRepository.java b/jihoonkang/src/main/java/com/example/blog/domain/post/repository/PostRepository.java index d7789a94..3b39216e 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/post/repository/PostRepository.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/repository/PostRepository.java @@ -1,11 +1,12 @@ package com.example.blog.domain.post.repository; import com.example.blog.domain.post.entity.Post; +import com.example.blog.domain.post.entity.PostStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface PostRepository extends JpaRepository { - Page findByStatus(String status, Pageable pageable); + Page findByStatus(PostStatus status, Pageable pageable); } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/post/service/PostService.java b/jihoonkang/src/main/java/com/example/blog/domain/post/service/PostService.java index 3260e958..1f1b193c 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/post/service/PostService.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/post/service/PostService.java @@ -5,6 +5,7 @@ import com.example.blog.domain.post.dto.PostResponse; import com.example.blog.domain.post.dto.PostUpdateRequest; import com.example.blog.domain.post.entity.Post; +import com.example.blog.domain.post.entity.PostStatus; import com.example.blog.domain.post.repository.PostRepository; import com.example.blog.domain.user.entity.User; import com.example.blog.domain.user.repository.UserRepository; @@ -32,7 +33,8 @@ public class PostService { public PostResponse create(Long userId, PostCreateRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - Post post = Post.of(user, request.title(), request.content(), request.status()); + PostStatus status = parseStatus(request.status()); + Post post = Post.of(user, request.title(), request.content(), status); return PostResponse.from(postRepository.save(post)); } @@ -41,7 +43,7 @@ public PostListResponse findAll(String status, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); Page posts; if (status != null && !status.isBlank()) { - posts = postRepository.findByStatus(status, pageable); + posts = postRepository.findByStatus(PostStatus.valueOf(status.toUpperCase()), pageable); } else { posts = postRepository.findAll(pageable); } @@ -64,7 +66,8 @@ public PostResponse update(Long userId, Long postId, PostUpdateRequest request) if (!post.getUser().getId().equals(userId)) { throw new BusinessException(ErrorCode.FORBIDDEN_ACCESS); } - post.update(request.title(), request.content(), request.status()); + PostStatus status = parseStatus(request.status()); + post.update(request.title(), request.content(), status); return PostResponse.from(post); } @@ -76,4 +79,21 @@ public void delete(Long userId, Long postId) { } postRepository.delete(post); } + + public PostResponse hidePost(Long userId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorCode.POST_NOT_FOUND)); + if (!post.getUser().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN_ACCESS); + } + post.hide(); + return PostResponse.from(post); + } + + private PostStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + return PostStatus.valueOf(status.toUpperCase()); + } } From 1d1fc9c7cb09df01f0b3b7169cc8efecd45f9d10 Mon Sep 17 00:00:00 2001 From: theSnackOverflow Date: Tue, 28 Apr 2026 21:07:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=B1=84=ED=83=9D=20=EC=95=A1=EC=85=98=20API=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=80=EB=AA=A8=20=EB=8C=93=EA=B8=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comment 엔티티에 accepted 필드 및 accept()/unaccept() 도메인 메서드 추가 - POST /api/v1/posts/{postId}/comments/{commentId}/accept — 댓글 채택 - 게시물 작성자만 채택 가능 - 게시물당 채택은 1개 (기존 채택 댓글은 자동 해제 후 신규 채택) - 이미 채택된 동일 댓글 재요청 시 멱등 200 반환 - 댓글 신고 엔드포인트 CommentController로 통합 - 부모 댓글 검증 오류 코드 INVALID_REPORT_TARGET → INVALID_PARENT_COMMENT 교체 --- .../comment/controller/CommentController.java | 23 +++++++++++++++ .../domain/comment/dto/CommentResponse.java | 2 ++ .../blog/domain/comment/entity/Comment.java | 12 ++++++++ .../comment/repository/CommentRepository.java | 3 ++ .../comment/service/CommentService.java | 28 +++++++++++++++++-- 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/jihoonkang/src/main/java/com/example/blog/domain/comment/controller/CommentController.java b/jihoonkang/src/main/java/com/example/blog/domain/comment/controller/CommentController.java index 6e91e421..ba28b32c 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/comment/controller/CommentController.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/comment/controller/CommentController.java @@ -4,6 +4,9 @@ import com.example.blog.domain.comment.dto.CommentResponse; import com.example.blog.domain.comment.dto.CommentUpdateRequest; import com.example.blog.domain.comment.service.CommentService; +import com.example.blog.domain.report.dto.ReportCommentRequest; +import com.example.blog.domain.report.dto.ReportResponse; +import com.example.blog.domain.report.service.ReportService; import com.example.blog.global.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -17,6 +20,7 @@ public class CommentController { private final CommentService commentService; + private final ReportService reportService; @PostMapping("/api/v1/posts/{postId}/comments") @ResponseStatus(HttpStatus.CREATED) @@ -50,4 +54,23 @@ public void delete( ) { commentService.delete(userId, commentId); } + + @PostMapping("/api/v1/posts/{postId}/comments/{commentId}/accept") + public ApiResponse accept( + @RequestHeader("X-User-Id") Long userId, + @PathVariable Long postId, + @PathVariable Long commentId + ) { + return ApiResponse.success(commentService.acceptComment(userId, postId, commentId)); + } + + @PostMapping("/api/v1/comments/{commentId}/reports") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse reportComment( + @RequestHeader("X-User-Id") Long reporterId, + @PathVariable Long commentId, + @RequestBody @Valid ReportCommentRequest request + ) { + return ApiResponse.success(reportService.reportComment(reporterId, commentId, request)); + } } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/comment/dto/CommentResponse.java b/jihoonkang/src/main/java/com/example/blog/domain/comment/dto/CommentResponse.java index d0ffa3eb..b6b81ec5 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/comment/dto/CommentResponse.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/comment/dto/CommentResponse.java @@ -11,6 +11,7 @@ public record CommentResponse( Long userId, String username, String content, + boolean accepted, Long parentCommentId, List replies, LocalDateTime createdAt @@ -26,6 +27,7 @@ public static CommentResponse from(Comment comment) { comment.getUser().getId(), comment.getUser().getUsername(), comment.getContent(), + comment.isAccepted(), comment.getParentComment() != null ? comment.getParentComment().getId() : null, replies, comment.getCreatedAt() diff --git a/jihoonkang/src/main/java/com/example/blog/domain/comment/entity/Comment.java b/jihoonkang/src/main/java/com/example/blog/domain/comment/entity/Comment.java index 6e4d2184..1290884e 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/comment/entity/Comment.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/comment/entity/Comment.java @@ -40,16 +40,28 @@ public class Comment extends BaseEntity { @OneToMany(mappedBy = "parentComment") private List replies = new ArrayList<>(); + @Column(nullable = false) + private boolean accepted; + public static Comment of(User user, Post post, String content, Comment parentComment) { Comment comment = new Comment(); comment.user = user; comment.post = post; comment.content = content; comment.parentComment = parentComment; + comment.accepted = false; return comment; } public void update(String content) { this.content = content; } + + public void accept() { + this.accepted = true; + } + + public void unaccept() { + this.accepted = false; + } } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/comment/repository/CommentRepository.java b/jihoonkang/src/main/java/com/example/blog/domain/comment/repository/CommentRepository.java index 791682e6..c17cdb5e 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/comment/repository/CommentRepository.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/comment/repository/CommentRepository.java @@ -4,8 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface CommentRepository extends JpaRepository { List findByPost_IdAndParentCommentIsNull(Long postId); + + Optional findByPost_IdAndAcceptedTrue(Long postId); } diff --git a/jihoonkang/src/main/java/com/example/blog/domain/comment/service/CommentService.java b/jihoonkang/src/main/java/com/example/blog/domain/comment/service/CommentService.java index 8295636b..577868bd 100644 --- a/jihoonkang/src/main/java/com/example/blog/domain/comment/service/CommentService.java +++ b/jihoonkang/src/main/java/com/example/blog/domain/comment/service/CommentService.java @@ -38,10 +38,10 @@ public CommentResponse create(Long userId, Long postId, CommentCreateRequest req parentComment = commentRepository.findById(request.parentCommentId()) .orElseThrow(() -> new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); if (!parentComment.getPost().getId().equals(postId)) { - throw new BusinessException(ErrorCode.INVALID_REPORT_TARGET); + throw new BusinessException(ErrorCode.INVALID_PARENT_COMMENT); } if (parentComment.getParentComment() != null) { - throw new BusinessException(ErrorCode.INVALID_REPORT_TARGET); + throw new BusinessException(ErrorCode.INVALID_PARENT_COMMENT); } } @@ -76,4 +76,28 @@ public void delete(Long userId, Long commentId) { } commentRepository.delete(comment); } + + public CommentResponse acceptComment(Long userId, Long postId, Long commentId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorCode.POST_NOT_FOUND)); + if (!post.getUser().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN_ACCESS); + } + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + if (!comment.getPost().getId().equals(postId)) { + throw new BusinessException(ErrorCode.INVALID_PARENT_COMMENT); + } + + if (comment.isAccepted()) { + return CommentResponse.from(comment); + } + + commentRepository.findByPost_IdAndAcceptedTrue(postId) + .ifPresent(Comment::unaccept); + + comment.accept(); + return CommentResponse.from(comment); + } }