diff --git a/src/main/java/com/example/demo/controller/CommentController.java b/src/main/java/com/example/demo/controller/CommentController.java new file mode 100644 index 00000000..abb8962b --- /dev/null +++ b/src/main/java/com/example/demo/controller/CommentController.java @@ -0,0 +1,47 @@ +package com.example.demo.controller; + +import com.example.demo.domain.comment.dto.CommentAdoptRequest; +import com.example.demo.domain.comment.dto.CommentCreateRequest; +import com.example.demo.domain.comment.dto.CommentCreateResponse; +import com.example.demo.domain.comment.dto.CommentStatusResponse; +import com.example.demo.domain.comment.service.CommentService; +import com.example.demo.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/api/posts/{postId}/comments") + public ResponseEntity> createComment( + @PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest request + ) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success( + "COMMENT_CREATE_SUCCESS", + "댓글 생성 성공", + commentService.createComment(postId, request) + )); + } + + @PostMapping("/api/comments/{commentId}/adoption") + public ResponseEntity> adoptComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentAdoptRequest request + ) { + return ResponseEntity.ok( + ApiResponse.success( + "COMMENT_ADOPT_SUCCESS", + "댓글 채택 성공", + commentService.adoptComment(commentId, request.userId()) + ) + ); + } +} diff --git a/src/main/java/com/example/demo/controller/ReportController.java b/src/main/java/com/example/demo/controller/ReportController.java new file mode 100644 index 00000000..2000772d --- /dev/null +++ b/src/main/java/com/example/demo/controller/ReportController.java @@ -0,0 +1,60 @@ +package com.example.demo.controller; + +import com.example.demo.domain.report.dto.ReportCreateRequest; +import com.example.demo.domain.report.dto.ReportCreateResponse; +import com.example.demo.domain.report.dto.ReportResolveRequest; +import com.example.demo.domain.report.dto.ReportResolveResponse; +import com.example.demo.domain.report.service.ReportService; +import com.example.demo.global.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @PostMapping("/api/posts/{postId}/reports") + public ResponseEntity> reportPost( + @PathVariable Long postId, + @Valid @RequestBody ReportCreateRequest request + ) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success( + "POST_REPORT_SUCCESS", + "게시글 신고 접수 성공", + reportService.reportPost(postId, request) + )); + } + + @PostMapping("/api/comments/{commentId}/reports") + public ResponseEntity> reportComment( + @PathVariable Long commentId, + @Valid @RequestBody ReportCreateRequest request + ) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success( + "COMMENT_REPORT_SUCCESS", + "댓글 신고 접수 성공", + reportService.reportComment(commentId, request) + )); + } + + @PatchMapping("/api/reports/{reportId}/resolve") + public ResponseEntity> resolveReport( + @PathVariable Long reportId, + @Valid @RequestBody ReportResolveRequest request + ) { + return ResponseEntity.ok( + ApiResponse.success( + "REPORT_RESOLVE_SUCCESS", + "신고 처리 완료", + reportService.resolveReport(reportId, request) + ) + ); + } +} diff --git a/src/main/java/com/example/demo/domain/comment/dto/CommentAdoptRequest.java b/src/main/java/com/example/demo/domain/comment/dto/CommentAdoptRequest.java new file mode 100644 index 00000000..6256f347 --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/dto/CommentAdoptRequest.java @@ -0,0 +1,9 @@ +package com.example.demo.domain.comment.dto; + +import jakarta.validation.constraints.NotNull; + +public record CommentAdoptRequest( + @NotNull(message = "userId는 필수입니다.") + Long userId +) { +} diff --git a/src/main/java/com/example/demo/domain/comment/dto/CommentCreateRequest.java b/src/main/java/com/example/demo/domain/comment/dto/CommentCreateRequest.java new file mode 100644 index 00000000..2e7d597c --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/dto/CommentCreateRequest.java @@ -0,0 +1,13 @@ +package com.example.demo.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CommentCreateRequest( + @NotNull(message = "userId는 필수입니다.") + Long userId, + + @NotBlank(message = "content는 필수입니다.") + String content +) { +} diff --git a/src/main/java/com/example/demo/domain/comment/dto/CommentCreateResponse.java b/src/main/java/com/example/demo/domain/comment/dto/CommentCreateResponse.java new file mode 100644 index 00000000..5c70a40d --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/dto/CommentCreateResponse.java @@ -0,0 +1,9 @@ +package com.example.demo.domain.comment.dto; + +import com.example.demo.domain.comment.entity.CommentStatus; + +public record CommentCreateResponse( + Long commentId, + CommentStatus status +) { +} diff --git a/src/main/java/com/example/demo/domain/comment/dto/CommentStatusResponse.java b/src/main/java/com/example/demo/domain/comment/dto/CommentStatusResponse.java new file mode 100644 index 00000000..2064cf8b --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/dto/CommentStatusResponse.java @@ -0,0 +1,9 @@ +package com.example.demo.domain.comment.dto; + +import com.example.demo.domain.comment.entity.CommentStatus; + +public record CommentStatusResponse( + Long commentId, + CommentStatus status +) { +} diff --git a/src/main/java/com/example/demo/domain/comment/entity/Comment.java b/src/main/java/com/example/demo/domain/comment/entity/Comment.java index 1a86dbfe..05465b24 100644 --- a/src/main/java/com/example/demo/domain/comment/entity/Comment.java +++ b/src/main/java/com/example/demo/domain/comment/entity/Comment.java @@ -32,5 +32,26 @@ public class Comment extends BaseEntity { @Column(nullable = false, columnDefinition = "TEXT") private String content; - -} \ No newline at end of file + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CommentStatus status; + + private Comment(User user, Post post, String content) { + this.user = user; + this.post = post; + this.content = content; + this.status = CommentStatus.ACTIVE; + } + + public static Comment of(User user, Post post, String content) { + return new Comment(user, post, content); + } + + public void hide() { + this.status = CommentStatus.HIDDEN; + } + + public void adopt() { + this.status = CommentStatus.ADOPTED; + } +} diff --git a/src/main/java/com/example/demo/domain/comment/entity/CommentStatus.java b/src/main/java/com/example/demo/domain/comment/entity/CommentStatus.java new file mode 100644 index 00000000..7bef496e --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/entity/CommentStatus.java @@ -0,0 +1,7 @@ +package com.example.demo.domain.comment.entity; + +public enum CommentStatus { + ACTIVE, + HIDDEN, + ADOPTED +} diff --git a/src/main/java/com/example/demo/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/demo/domain/comment/repository/CommentRepository.java new file mode 100644 index 00000000..1f2ac290 --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/repository/CommentRepository.java @@ -0,0 +1,9 @@ +package com.example.demo.domain.comment.repository; + +import com.example.demo.domain.comment.entity.Comment; +import com.example.demo.domain.comment.entity.CommentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + boolean existsByPostIdAndStatus(Long postId, CommentStatus status); +} diff --git a/src/main/java/com/example/demo/domain/comment/service/CommentService.java b/src/main/java/com/example/demo/domain/comment/service/CommentService.java new file mode 100644 index 00000000..7a1e353c --- /dev/null +++ b/src/main/java/com/example/demo/domain/comment/service/CommentService.java @@ -0,0 +1,78 @@ +package com.example.demo.domain.comment.service; + +import com.example.demo.domain.comment.dto.CommentCreateRequest; +import com.example.demo.domain.comment.dto.CommentCreateResponse; +import com.example.demo.domain.comment.dto.CommentStatusResponse; +import com.example.demo.domain.comment.entity.Comment; +import com.example.demo.domain.comment.entity.CommentStatus; +import com.example.demo.domain.comment.repository.CommentRepository; +import com.example.demo.domain.post.entity.Post; +import com.example.demo.domain.post.entity.PostStatus; +import com.example.demo.domain.post.repository.PostRepository; +import com.example.demo.domain.user.entity.User; +import com.example.demo.domain.user.repository.UserRepository; +import com.example.demo.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + @Transactional + public CommentCreateResponse createComment(Long postId, CommentCreateRequest request) { + Post post = findPost(postId); + User user = findUser(request.userId()); + + if (post.getStatus() == PostStatus.HIDDEN) { + throw new CustomException(HttpStatus.BAD_REQUEST, "POST_HIDDEN", "숨김 처리된 게시글에는 댓글을 작성할 수 없습니다."); + } + + Comment comment = commentRepository.save(Comment.of(user, post, request.content())); + return new CommentCreateResponse(comment.getId(), comment.getStatus()); + } + + @Transactional + public CommentStatusResponse adoptComment(Long commentId, Long userId) { + Comment comment = findComment(commentId); + Post post = comment.getPost(); + + if (!post.getUser().getId().equals(userId)) { + throw new CustomException(HttpStatus.FORBIDDEN, "FORBIDDEN", "댓글 채택 권한이 없습니다."); + } + if (post.getStatus() == PostStatus.HIDDEN) { + throw new CustomException(HttpStatus.BAD_REQUEST, "POST_HIDDEN", "숨김 처리된 게시글에서는 댓글을 채택할 수 없습니다."); + } + if (comment.getStatus() == CommentStatus.HIDDEN) { + throw new CustomException(HttpStatus.BAD_REQUEST, "COMMENT_HIDDEN", "숨김 처리된 댓글은 채택할 수 없습니다."); + } + if (commentRepository.existsByPostIdAndStatus(post.getId(), CommentStatus.ADOPTED)) { + throw new CustomException(HttpStatus.CONFLICT, "COMMENT_ALREADY_ADOPTED", "이미 채택된 댓글이 있습니다."); + } + + comment.adopt(); + return new CommentStatusResponse(comment.getId(), comment.getStatus()); + } + + public Comment findComment(Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "해당 댓글을 찾을 수 없습니다.")); + } + + private Post findPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", "해당 게시글을 찾을 수 없습니다.")); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/example/demo/domain/post/dto/PostDetailResponse.java b/src/main/java/com/example/demo/domain/post/dto/PostDetailResponse.java index 3d23edd7..da9da7ee 100644 --- a/src/main/java/com/example/demo/domain/post/dto/PostDetailResponse.java +++ b/src/main/java/com/example/demo/domain/post/dto/PostDetailResponse.java @@ -1,5 +1,7 @@ package com.example.demo.domain.post.dto; +import com.example.demo.domain.post.entity.PostStatus; + import java.time.LocalDateTime; public record PostDetailResponse( @@ -7,6 +9,7 @@ public record PostDetailResponse( String title, String content, String imageUrl, + PostStatus status, Long authorId, String author, LocalDateTime createdAt, diff --git a/src/main/java/com/example/demo/domain/post/dto/PostListItemResponse.java b/src/main/java/com/example/demo/domain/post/dto/PostListItemResponse.java index a61543c0..eac43b94 100644 --- a/src/main/java/com/example/demo/domain/post/dto/PostListItemResponse.java +++ b/src/main/java/com/example/demo/domain/post/dto/PostListItemResponse.java @@ -1,5 +1,7 @@ package com.example.demo.domain.post.dto; +import com.example.demo.domain.post.entity.PostStatus; + import java.time.LocalDateTime; public record PostListItemResponse( @@ -7,6 +9,7 @@ public record PostListItemResponse( String title, String content, String thumbnailImageUrl, + PostStatus status, String author, LocalDateTime createdAt ) { diff --git a/src/main/java/com/example/demo/domain/post/entity/Post.java b/src/main/java/com/example/demo/domain/post/entity/Post.java index 26619e34..58810854 100644 --- a/src/main/java/com/example/demo/domain/post/entity/Post.java +++ b/src/main/java/com/example/demo/domain/post/entity/Post.java @@ -38,6 +38,10 @@ public class Post extends BaseEntity { @Column(name = "image_url") private String imageUrl; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PostStatus status; + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List comments = new ArrayList<>(); @@ -46,6 +50,7 @@ private Post(User user, String title, String content, String imageUrl) { this.title = title; this.content = content; this.imageUrl = imageUrl; + this.status = PostStatus.ACTIVE; } public static Post of(User user, String title, String content, String imageUrl) { @@ -61,4 +66,8 @@ public void update(String title, String content, String imageUrl) { } this.imageUrl = imageUrl; } -} \ No newline at end of file + + public void hide() { + this.status = PostStatus.HIDDEN; + } +} diff --git a/src/main/java/com/example/demo/domain/post/entity/PostStatus.java b/src/main/java/com/example/demo/domain/post/entity/PostStatus.java new file mode 100644 index 00000000..f79ce751 --- /dev/null +++ b/src/main/java/com/example/demo/domain/post/entity/PostStatus.java @@ -0,0 +1,6 @@ +package com.example.demo.domain.post.entity; + +public enum PostStatus { + ACTIVE, + HIDDEN +} diff --git a/src/main/java/com/example/demo/domain/post/service/PostService.java b/src/main/java/com/example/demo/domain/post/service/PostService.java index 281d2d1e..97ba1b96 100644 --- a/src/main/java/com/example/demo/domain/post/service/PostService.java +++ b/src/main/java/com/example/demo/domain/post/service/PostService.java @@ -31,6 +31,7 @@ public PostListResponse getPosts(int page, int size) { post.getTitle(), post.getContent(), post.getImageUrl(), + post.getStatus(), post.getUser().getName(), post.getCreatedAt() )) @@ -53,6 +54,7 @@ public PostDetailResponse getPost(Long postId) { post.getTitle(), post.getContent(), post.getImageUrl(), + post.getStatus(), post.getUser().getId(), post.getUser().getName(), post.getCreatedAt(), diff --git a/src/main/java/com/example/demo/domain/report/dto/ReportCreateRequest.java b/src/main/java/com/example/demo/domain/report/dto/ReportCreateRequest.java new file mode 100644 index 00000000..6107cbfe --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/dto/ReportCreateRequest.java @@ -0,0 +1,13 @@ +package com.example.demo.domain.report.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ReportCreateRequest( + @NotNull(message = "reporterId는 필수입니다.") + Long reporterId, + + @NotBlank(message = "reason은 필수입니다.") + String reason +) { +} diff --git a/src/main/java/com/example/demo/domain/report/dto/ReportCreateResponse.java b/src/main/java/com/example/demo/domain/report/dto/ReportCreateResponse.java new file mode 100644 index 00000000..77b34717 --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/dto/ReportCreateResponse.java @@ -0,0 +1,11 @@ +package com.example.demo.domain.report.dto; + +import com.example.demo.domain.report.entity.ReportStatus; +import com.example.demo.domain.report.entity.ReportTargetType; + +public record ReportCreateResponse( + Long reportId, + ReportTargetType targetType, + ReportStatus status +) { +} diff --git a/src/main/java/com/example/demo/domain/report/dto/ReportResolveRequest.java b/src/main/java/com/example/demo/domain/report/dto/ReportResolveRequest.java new file mode 100644 index 00000000..e071eecc --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/dto/ReportResolveRequest.java @@ -0,0 +1,13 @@ +package com.example.demo.domain.report.dto; + +import com.example.demo.domain.report.entity.ReportResolutionType; +import jakarta.validation.constraints.NotNull; + +public record ReportResolveRequest( + @NotNull(message = "resolverId는 필수입니다.") + Long resolverId, + + @NotNull(message = "resolutionType은 필수입니다.") + ReportResolutionType resolutionType +) { +} diff --git a/src/main/java/com/example/demo/domain/report/dto/ReportResolveResponse.java b/src/main/java/com/example/demo/domain/report/dto/ReportResolveResponse.java new file mode 100644 index 00000000..4f565858 --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/dto/ReportResolveResponse.java @@ -0,0 +1,11 @@ +package com.example.demo.domain.report.dto; + +import com.example.demo.domain.report.entity.ReportResolutionType; +import com.example.demo.domain.report.entity.ReportStatus; + +public record ReportResolveResponse( + Long reportId, + ReportStatus status, + ReportResolutionType resolutionType +) { +} diff --git a/src/main/java/com/example/demo/domain/report/entity/Report.java b/src/main/java/com/example/demo/domain/report/entity/Report.java new file mode 100644 index 00000000..3dc7a4f7 --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/entity/Report.java @@ -0,0 +1,74 @@ +package com.example.demo.domain.report.entity; + +import com.example.demo.domain.comment.entity.Comment; +import com.example.demo.domain.post.entity.Post; +import com.example.demo.domain.user.entity.User; +import com.example.demo.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Report extends BaseEntity { + + @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 = "resolver_id") + private User resolver; + + @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, length = 20) + private ReportTargetType targetType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ReportStatus status; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private ReportResolutionType resolutionType; + + @Column(nullable = false, length = 255) + private String reason; + + private Report(User reporter, Post post, Comment comment, ReportTargetType targetType, String reason) { + this.reporter = reporter; + this.post = post; + this.comment = comment; + this.targetType = targetType; + this.reason = reason; + this.status = ReportStatus.PENDING; + } + + public static Report forPost(User reporter, Post post, String reason) { + return new Report(reporter, post, null, ReportTargetType.POST, reason); + } + + public static Report forComment(User reporter, Comment comment, String reason) { + return new Report(reporter, null, comment, ReportTargetType.COMMENT, reason); + } + + public void resolve(User resolver, ReportResolutionType resolutionType) { + this.resolver = resolver; + this.resolutionType = resolutionType; + this.status = ReportStatus.RESOLVED; + } +} diff --git a/src/main/java/com/example/demo/domain/report/entity/ReportResolutionType.java b/src/main/java/com/example/demo/domain/report/entity/ReportResolutionType.java new file mode 100644 index 00000000..581e61b8 --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/entity/ReportResolutionType.java @@ -0,0 +1,7 @@ +package com.example.demo.domain.report.entity; + +public enum ReportResolutionType { + HIDE_POST, + HIDE_COMMENT, + REJECT +} diff --git a/src/main/java/com/example/demo/domain/report/entity/ReportStatus.java b/src/main/java/com/example/demo/domain/report/entity/ReportStatus.java new file mode 100644 index 00000000..41b911d3 --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/entity/ReportStatus.java @@ -0,0 +1,6 @@ +package com.example.demo.domain.report.entity; + +public enum ReportStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/example/demo/domain/report/entity/ReportTargetType.java b/src/main/java/com/example/demo/domain/report/entity/ReportTargetType.java new file mode 100644 index 00000000..b086f28c --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/entity/ReportTargetType.java @@ -0,0 +1,6 @@ +package com.example.demo.domain.report.entity; + +public enum ReportTargetType { + POST, + COMMENT +} diff --git a/src/main/java/com/example/demo/domain/report/repository/ReportRepository.java b/src/main/java/com/example/demo/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..b277c70c --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/repository/ReportRepository.java @@ -0,0 +1,11 @@ +package com.example.demo.domain.report.repository; + +import com.example.demo.domain.report.entity.Report; +import com.example.demo.domain.report.entity.ReportStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + boolean existsByReporterIdAndPostIdAndStatus(Long reporterId, Long postId, ReportStatus status); + + boolean existsByReporterIdAndCommentIdAndStatus(Long reporterId, Long commentId, ReportStatus status); +} diff --git a/src/main/java/com/example/demo/domain/report/service/ReportService.java b/src/main/java/com/example/demo/domain/report/service/ReportService.java new file mode 100644 index 00000000..e481fb9a --- /dev/null +++ b/src/main/java/com/example/demo/domain/report/service/ReportService.java @@ -0,0 +1,122 @@ +package com.example.demo.domain.report.service; + +import com.example.demo.domain.comment.entity.Comment; +import com.example.demo.domain.comment.entity.CommentStatus; +import com.example.demo.domain.comment.service.CommentService; +import com.example.demo.domain.post.entity.Post; +import com.example.demo.domain.post.entity.PostStatus; +import com.example.demo.domain.post.repository.PostRepository; +import com.example.demo.domain.report.dto.ReportCreateRequest; +import com.example.demo.domain.report.dto.ReportCreateResponse; +import com.example.demo.domain.report.dto.ReportResolveRequest; +import com.example.demo.domain.report.dto.ReportResolveResponse; +import com.example.demo.domain.report.entity.Report; +import com.example.demo.domain.report.entity.ReportResolutionType; +import com.example.demo.domain.report.entity.ReportStatus; +import com.example.demo.domain.report.repository.ReportRepository; +import com.example.demo.domain.user.entity.User; +import com.example.demo.domain.user.repository.UserRepository; +import com.example.demo.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final CommentService commentService; + + @Transactional + public ReportCreateResponse reportPost(Long postId, ReportCreateRequest request) { + Post post = findPost(postId); + User reporter = findUser(request.reporterId()); + + if (post.getUser().getId().equals(reporter.getId())) { + throw new CustomException(HttpStatus.BAD_REQUEST, "SELF_REPORT_NOT_ALLOWED", "본인 게시글은 신고할 수 없습니다."); + } + if (reportRepository.existsByReporterIdAndPostIdAndStatus(reporter.getId(), postId, ReportStatus.PENDING)) { + throw new CustomException(HttpStatus.CONFLICT, "DUPLICATE_REPORT", "이미 처리 대기 중인 게시글 신고가 있습니다."); + } + + Report report = reportRepository.save(Report.forPost(reporter, post, request.reason())); + return new ReportCreateResponse(report.getId(), report.getTargetType(), report.getStatus()); + } + + @Transactional + public ReportCreateResponse reportComment(Long commentId, ReportCreateRequest request) { + Comment comment = commentService.findComment(commentId); + User reporter = findUser(request.reporterId()); + + if (comment.getUser().getId().equals(reporter.getId())) { + throw new CustomException(HttpStatus.BAD_REQUEST, "SELF_REPORT_NOT_ALLOWED", "본인 댓글은 신고할 수 없습니다."); + } + if (reportRepository.existsByReporterIdAndCommentIdAndStatus(reporter.getId(), commentId, ReportStatus.PENDING)) { + throw new CustomException(HttpStatus.CONFLICT, "DUPLICATE_REPORT", "이미 처리 대기 중인 댓글 신고가 있습니다."); + } + + Report report = reportRepository.save(Report.forComment(reporter, comment, request.reason())); + return new ReportCreateResponse(report.getId(), report.getTargetType(), report.getStatus()); + } + + @Transactional + public ReportResolveResponse resolveReport(Long reportId, ReportResolveRequest request) { + Report report = findReport(reportId); + User resolver = findUser(request.resolverId()); + + if (report.getStatus() == ReportStatus.RESOLVED) { + throw new CustomException(HttpStatus.CONFLICT, "REPORT_ALREADY_RESOLVED", "이미 처리 완료된 신고입니다."); + } + + applyResolution(report, request.resolutionType()); + report.resolve(resolver, request.resolutionType()); + + return new ReportResolveResponse(report.getId(), report.getStatus(), report.getResolutionType()); + } + + private void applyResolution(Report report, ReportResolutionType resolutionType) { + switch (resolutionType) { + case HIDE_POST -> { + if (report.getPost() == null) { + throw new CustomException(HttpStatus.BAD_REQUEST, "INVALID_RESOLUTION", "댓글 신고에는 게시글 숨김 처리를 할 수 없습니다."); + } + if (report.getPost().getStatus() == PostStatus.HIDDEN) { + throw new CustomException(HttpStatus.CONFLICT, "POST_ALREADY_HIDDEN", "이미 숨김 처리된 게시글입니다."); + } + report.getPost().hide(); + } + case HIDE_COMMENT -> { + if (report.getComment() == null) { + throw new CustomException(HttpStatus.BAD_REQUEST, "INVALID_RESOLUTION", "게시글 신고에는 댓글 숨김 처리를 할 수 없습니다."); + } + if (report.getComment().getStatus() == CommentStatus.HIDDEN) { + throw new CustomException(HttpStatus.CONFLICT, "COMMENT_ALREADY_HIDDEN", "이미 숨김 처리된 댓글입니다."); + } + report.getComment().hide(); + } + case REJECT -> { + // Intentionally left blank. Reject only changes the report state. + } + } + } + + private Report findReport(Long reportId) { + return reportRepository.findById(reportId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "REPORT_NOT_FOUND", "해당 신고를 찾을 수 없습니다.")); + } + + private Post findPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", "해당 게시글을 찾을 수 없습니다.")); + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다.")); + } +} diff --git a/src/main/java/com/example/demo/domain/user/entity/User.java b/src/main/java/com/example/demo/domain/user/entity/User.java index d6c94ecc..b11765ee 100644 --- a/src/main/java/com/example/demo/domain/user/entity/User.java +++ b/src/main/java/com/example/demo/domain/user/entity/User.java @@ -14,7 +14,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "user") +@Table(name = "users") public class User extends BaseEntity { // 유저 아이디 @@ -31,4 +31,12 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user") private List comments = new ArrayList<>(); -} \ No newline at end of file + + private User(String name) { + this.name = name; + } + + public static User of(String name) { + return new User(name); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 88bf6644..70cca77c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,5 +4,5 @@ spring.datasource.url=jdbc:mysql://localhost:3306/leets_db spring.datasource.username=root spring.datasource.password= -spring.jpa.hibernate.ddl-auto=create -spring.jpa.show-sql=true \ No newline at end of file +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 00000000..09e54c84 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false