diff --git a/src/main/java/com/payper/server/comment/controller/CommentController.java b/src/main/java/com/payper/server/comment/controller/CommentController.java new file mode 100644 index 0000000..fed250f --- /dev/null +++ b/src/main/java/com/payper/server/comment/controller/CommentController.java @@ -0,0 +1,78 @@ +package com.payper.server.comment.controller; + +import com.payper.server.comment.dto.CommentRequest; +import com.payper.server.comment.dto.CommentResponse; +import com.payper.server.comment.service.CommentService; +import com.payper.server.global.response.ApiResponse; +import com.payper.server.security.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + + /** + * 댓글 수정 + * 작성자만 수정 가능 + */ + @PutMapping("/{commentId}") + public ResponseEntity> updateComment( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long commentId, + @RequestBody @Valid CommentRequest.UpdateComment request + ) { + commentService.updateComment(user.getId(), commentId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + /** + * 댓글 삭제 + * 작성자만 삭제 가능 + * + * 자식 댓글은 삭제하지 않음 + */ + @DeleteMapping("/{commentId}") + public ResponseEntity> deleteComment( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long commentId) { + + commentService.deleteComment(user.getId(), commentId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + /** + * 내가 쓴 댓글 조회 + * + * 무한 스크롤 방식 + * + * 정렬: 최신 순 + */ + @GetMapping("/me") + public ResponseEntity> getMyComments( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size + ) { + CommentResponse.MyCommentList response = commentService.getMyComments(user.getId(), cursorId, size); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + /** + * 자식 댓글 조회 + */ + @GetMapping("/{parentId}/replies") + public ResponseEntity> getReplies( + @PathVariable Long parentId, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size + ) { + CommentResponse.CommentList response = commentService.getReplies(parentId, cursorId, size); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/comment/dto/CommentRequest.java b/src/main/java/com/payper/server/comment/dto/CommentRequest.java new file mode 100644 index 0000000..0b193ad --- /dev/null +++ b/src/main/java/com/payper/server/comment/dto/CommentRequest.java @@ -0,0 +1,29 @@ +package com.payper.server.comment.dto; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class CommentRequest { + + /** + * 댓글 작성 DTO + */ + public record CreateComment( + @NotBlank(message = "댓글을 적어주세요.") + @Size(max = 21800, message = "댓글은 21,800자 이하여야 합니다.") + String content, + + @Nullable + Long parentCommentId + ) {} + + /** + * 댓글 수정 DTO + */ + public record UpdateComment( + @NotBlank(message = "댓글을 적어주세요.") + @Size(max = 21800, message = "댓글은 21,800자 이하여야 합니다.") + String content + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/comment/dto/CommentResponse.java b/src/main/java/com/payper/server/comment/dto/CommentResponse.java new file mode 100644 index 0000000..3a83871 --- /dev/null +++ b/src/main/java/com/payper/server/comment/dto/CommentResponse.java @@ -0,0 +1,91 @@ +package com.payper.server.comment.dto; + +import com.payper.server.comment.entity.Comment; + +import java.time.LocalDateTime; +import java.util.List; + +public class CommentResponse { + + /** + * Post에 달린 Comment 리스트 + */ + public record CommentList( + List comments, + Long nextCursor, + boolean hasNext + ) { + public static CommentList from(List comments, Long nextCursor, boolean hasNext) { + return new CommentList( + comments.stream() + .map(CommentResponse.CommentItem::from) + .toList(), + nextCursor, + hasNext + ); + } + } + + /** + * Comment Item + */ + public record CommentItem( + Long id, + String userName, + Long parentCommentId, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static CommentItem from(Comment comment) { + return new CommentItem( + comment.getId(), + comment.getUser().getName(), + comment.getParentComment() != null ? comment.getParentComment().getId() : null, + comment.isDeleted() ? "[삭제된 댓글입니다]" : comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } + } + + /** + * 내가 작성한 Comment 리스트 + */ + public record MyCommentList( + List comments, + Long nextCursor, + boolean hasNext + ) { + public static MyCommentList from(List comments, Long nextCursor, boolean hasNext) { + return new MyCommentList( + comments.stream() + .map(CommentResponse.MyCommentItem::from) + .toList(), + nextCursor, + hasNext + ); + } + } + + /** + * 내가 작성한 Comment Item + */ + public record MyCommentItem( + Long id, + Long postId, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static MyCommentItem from(Comment comment) { + return new MyCommentItem( + comment.getId(), + comment.getPost().getId(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/comment/entity/Comment.java b/src/main/java/com/payper/server/comment/entity/Comment.java index 3894252..4194ba0 100644 --- a/src/main/java/com/payper/server/comment/entity/Comment.java +++ b/src/main/java/com/payper/server/comment/entity/Comment.java @@ -71,7 +71,33 @@ public class Comment extends BaseTimeEntity { * soft delete */ public void delete() { + if (this.isDeleted) { // 멱등성 고려 + return; + } + this.isDeleted = true; - this.deletedAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + this.deletedAt = LocalDateTime.now(); + this.post.decreaseCommentCount(); + } + + /** + * 댓글 생성 + */ + public static Comment create(Post post, User user, Comment parentComment, String content) { + return Comment.builder() + .post(post) + .user(user) + .parentComment(parentComment) + .content(content) + .isDeleted(false) + .deletedAt(null) + .build(); + } + + /** + * 댓글 수정 + */ + public void update(String content) { + this.content = content; } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/comment/repository/CommentRepository.java b/src/main/java/com/payper/server/comment/repository/CommentRepository.java new file mode 100644 index 0000000..5b41cc6 --- /dev/null +++ b/src/main/java/com/payper/server/comment/repository/CommentRepository.java @@ -0,0 +1,124 @@ +package com.payper.server.comment.repository; + +import com.payper.server.comment.entity.Comment; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface CommentRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); + + @Query(""" + select c from Comment c + join fetch c.user + where c.post.id = :postId + and c.parentComment is null + and ( + c.isDeleted = false + or exists ( + select 1 from Comment child + where child.parentComment = c + and child.isDeleted = false + ) + ) + order by c.createdAt asc, c.id asc + """) + Slice findParent(@Param("postId") Long postId, Pageable pageable); + + @Query(""" + select c from Comment c + join fetch c.user + where c.post.id = :postId + and c.parentComment is null + and ( + c.isDeleted = false + or exists ( + select 1 from Comment child + where child.parentComment = c + and child.isDeleted = false + ) + ) + and ( + c.createdAt > :createdAt + or (c.createdAt = :createdAt and c.id > :cursorId) + ) + order by c.createdAt asc, c.id asc + """) + Slice findParentNext( + @Param("postId") Long postId, + @Param("cursorId") Long cursorId, + @Param("createdAt") LocalDateTime createdAt, + Pageable pageable + ); + + @Query(""" + select c from Comment c + join fetch c.user + where c.parentComment.id = :parentId + and c.isDeleted = false + order by c.createdAt asc, c.id asc + """) + Slice findReply(@Param("parentId") Long parentId, Pageable pageable); + + @Query(""" + select c from Comment c + join fetch c.user + where c.parentComment.id = :parentId + and c.isDeleted = false + and ( + c.createdAt > :createdAt + or (c.createdAt = :createdAt and c.id > :cursorId) + ) + order by c.createdAt asc, c.id asc + """) + Slice findReplyNext( + @Param("parentId") Long parentId, + @Param("cursorId") Long cursorId, + @Param("createdAt") LocalDateTime createdAt, + Pageable pageable + ); + + @Query(""" + select c from Comment c + where c.user.id = :userId + and c.isDeleted = false + order by c.createdAt desc, c.id desc + """) + Slice findFirstMyCommentPage(@Param("userId") Long userId, Pageable pageable); + + @Query(""" + select c from Comment c + where c.user.id = :userId + and c.isDeleted = false + and ( + c.createdAt < :createdAt + or (c.createdAt = :createdAt and c.id < :cursorId) + ) + order by c.createdAt desc, c.id desc + """) + Slice findNextMyCommentPage( + @Param("userId") Long userId, + @Param("cursorId") Long cursorId, + @Param("createdAt") LocalDateTime createdAt, + Pageable pageable + ); + + @Modifying + @Query(""" + update Comment c + set c.isDeleted = true, + c.deletedAt = :now + where c.post.id = :postId + and c.isDeleted = false + """) + long softDeleteByPostId(@Param("postId") Long postId, @Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/payper/server/comment/service/CommentService.java b/src/main/java/com/payper/server/comment/service/CommentService.java new file mode 100644 index 0000000..0a977d8 --- /dev/null +++ b/src/main/java/com/payper/server/comment/service/CommentService.java @@ -0,0 +1,200 @@ +package com.payper.server.comment.service; + +import com.payper.server.comment.dto.CommentRequest; +import com.payper.server.comment.dto.CommentResponse; +import com.payper.server.comment.entity.Comment; +import com.payper.server.comment.repository.CommentRepository; +import com.payper.server.global.exception.ApiException; +import com.payper.server.global.response.ErrorCode; +import com.payper.server.post.entity.Post; +import com.payper.server.post.repository.PostRepository; +import com.payper.server.user.entity.User; +import com.payper.server.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CommentService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + /** + * 댓글 작성 + */ + @Transactional + public Long createComment(Long userId, Long postId, CommentRequest.CreateComment request) { + // 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + + // 게시글 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); + + // 삭제 또는 비활성화된 post에는 댓글 작성 불가 + if (!post.isCommentable()) { + throw new ApiException(ErrorCode.POST_NOT_COMMENTABLE); + } + + Comment parentComment = request.parentCommentId() != null + ? getValidatedParentComment(request.parentCommentId(), postId) + : null; + + // 댓글 생성 + Comment comment = Comment.create(post, user, parentComment, request.content()); + commentRepository.save(comment); + + post.increaseCommentCount(); + + log.info("댓글 생성 완료 - commentId: {}, userId: {}, postId: {}", comment.getId(), userId, postId); + return comment.getId(); + } + + // 요청 body로 받은 parent comment id 검증 + private Comment getValidatedParentComment(Long parentCommentId, Long postId) { + Comment parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!parentComment.getPost().getId().equals(postId)) { + throw new ApiException(ErrorCode.INVALID_PARENT_COMMENT); + } + + return parentComment; + } + + /** + * 댓글 수정 + */ + @Transactional + public void updateComment(Long userId, Long commentId, CommentRequest.UpdateComment request) { + // 댓글 조회 + Comment comment = commentRepository.findByIdAndIsDeletedFalse(commentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 댓글 수정 권한 조회 + if(!userId.equals(comment.getUser().getId())) { + throw new ApiException(ErrorCode.NOT_COMMENT_AUTHOR); + } + + // 댓글 수정 + comment.update(request.content()); + + log.info("댓글 수정 완료 - commentId: {}", comment.getId()); + } + + /** + * 댓글 삭제 + */ + @Transactional + public void deleteComment(Long userId, Long commentId) { + // 댓글 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + // 댓글 삭제 권한 조회 + if(!userId.equals(comment.getUser().getId())) { + throw new ApiException(ErrorCode.NOT_COMMENT_AUTHOR); + } + + comment.delete(); + } + + /** + * 내가 작성한 댓글 조회 + */ + @Transactional(readOnly = true) + public CommentResponse.MyCommentList getMyComments(Long userId, Long cursorId, int size) { + Pageable pageable = PageRequest.of(0, size); + + Slice comments; + + if (cursorId == null) { // 첫 요청 + comments = commentRepository.findFirstMyCommentPage(userId, pageable); + } else { // 첫 요청이 아닌 경우 + Comment lastComment = commentRepository.findById(cursorId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + comments = commentRepository.findNextMyCommentPage(userId, cursorId, lastComment.getCreatedAt(), pageable); + } + + Long nextCursor = comments.hasNext() ? comments.getContent().get(comments.getContent().size()-1).getId() : null; + return CommentResponse.MyCommentList.from(comments.getContent(), nextCursor, comments.hasNext()); + } + + /** + * 게시글 댓글 조회 + */ + @Transactional(readOnly = true) + public CommentResponse.CommentList getPostComments(Long postId, Long cursorId, int size) { + + // 게시글 존재 및 삭제 여부 확인 + if(!postRepository.existsByIdAndIsDeletedFalse(postId)) { + throw new ApiException(ErrorCode.POST_NOT_FOUND); + } + + Pageable pageable = PageRequest.of(0, size); + + Slice comments; + + if (cursorId == null) { + comments = commentRepository.findParent(postId, pageable); + } else { + Comment lastComment = commentRepository.findById(cursorId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + comments = commentRepository.findParentNext(postId, cursorId, lastComment.getCreatedAt(), pageable); + } + + Long nextCursor = comments.hasNext() ? comments.getContent().get(comments.getContent().size()-1).getId() : null; + return CommentResponse.CommentList.from(comments.getContent(), nextCursor, comments.hasNext()); + } + + @Transactional(readOnly = true) + public CommentResponse.CommentList getReplies(Long parentId, Long cursorId, int size) { + + // 부모 댓글 존재 여부 확인 + if(!commentRepository.existsById(parentId)) { + throw new ApiException(ErrorCode.COMMENT_NOT_FOUND); + } + + Pageable pageable = PageRequest.of(0, size); + + Slice comments; + + if (cursorId == null) { + comments = commentRepository.findReply(parentId, pageable); + } else { + Comment lastComment = commentRepository.findById(cursorId) + .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); + + comments = commentRepository.findReplyNext(parentId, cursorId, lastComment.getCreatedAt(), pageable); + } + + Long nextCursor = comments.hasNext() ? comments.getContent().get(comments.getContent().size()-1).getId() : null; + return CommentResponse.CommentList.from(comments.getContent(), nextCursor, comments.hasNext()); + } + + /** + * 게시글 삭제 시 댓글 삭제(soft delete) + */ + @Transactional(propagation = REQUIRES_NEW) + public void softDeleteByPostId(Long postId) { + long deletedCount = commentRepository.softDeleteByPostId(postId, LocalDateTime.now()); + if (deletedCount > 0) { + postRepository.decreaseCommentCount(postId, deletedCount); + } + } +} diff --git a/src/main/java/com/payper/server/global/response/ErrorCode.java b/src/main/java/com/payper/server/global/response/ErrorCode.java index 7a042d0..96649c2 100644 --- a/src/main/java/com/payper/server/global/response/ErrorCode.java +++ b/src/main/java/com/payper/server/global/response/ErrorCode.java @@ -35,10 +35,16 @@ public enum ErrorCode { // USER USER_NOT_FOUND("USER-001", HttpStatus.NOT_FOUND, "User Not Found"), - NOT_POSTING_USER("USER-002", HttpStatus.FORBIDDEN, "Not Posting User"), // POST POST_NOT_FOUND("POST-001", HttpStatus.NOT_FOUND, "Post Not Found"), + NOT_POST_AUTHOR("POST-002", HttpStatus.FORBIDDEN, "Not Post Author"), + + // COMMENT + COMMENT_NOT_FOUND("COMMENT-001", HttpStatus.NOT_FOUND, "Comment Not Found"), + NOT_COMMENT_AUTHOR("COMMENT-002", HttpStatus.FORBIDDEN, "Not Comment Author"), + POST_NOT_COMMENTABLE("COMMENT-003", HttpStatus.BAD_REQUEST, "Cannot comment on deleted or inactive post"), + INVALID_PARENT_COMMENT("COMMENT-004", HttpStatus.BAD_REQUEST, "Invalid parent comment"), // MERCHANT MERCHANT_NOT_FOUND("MERCHANT-001", HttpStatus.NOT_FOUND, "Merchant Not Found") diff --git a/src/main/java/com/payper/server/merchant/controller/MerchantController.java b/src/main/java/com/payper/server/merchant/controller/MerchantController.java index 29def83..565f1ed 100644 --- a/src/main/java/com/payper/server/merchant/controller/MerchantController.java +++ b/src/main/java/com/payper/server/merchant/controller/MerchantController.java @@ -3,9 +3,11 @@ import com.payper.server.global.response.ApiResponse; import com.payper.server.post.dto.PostRequest; import com.payper.server.post.service.PostService; +import com.payper.server.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -24,11 +26,11 @@ public class MerchantController { */ @PostMapping("/{merchantId}/posts") public ResponseEntity> createPost( - // TODO @AuthenticationPrincipal CustomUserDetails user, + @AuthenticationPrincipal CustomUserDetails user, @PathVariable Long merchantId, @RequestBody @Valid PostRequest.CreatePost request ) { - Long postId = postService.createPost(1L, merchantId, request); + Long postId = postService.createPost(user.getId(), merchantId, request); return ResponseEntity.status(201).body(ApiResponse.created(postId)); } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/post/controller/PostController.java b/src/main/java/com/payper/server/post/controller/PostController.java index 58ca877..61e6504 100644 --- a/src/main/java/com/payper/server/post/controller/PostController.java +++ b/src/main/java/com/payper/server/post/controller/PostController.java @@ -1,18 +1,24 @@ package com.payper.server.post.controller; +import com.payper.server.comment.dto.CommentRequest; +import com.payper.server.comment.dto.CommentResponse; +import com.payper.server.comment.service.CommentService; import com.payper.server.global.response.ApiResponse; import com.payper.server.post.dto.PostRequest; import com.payper.server.post.dto.PostResponse; import com.payper.server.post.dto.PostSortType; import com.payper.server.post.entity.PostType; import com.payper.server.post.service.PostService; +import com.payper.server.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @RestController @@ -20,6 +26,7 @@ @RequiredArgsConstructor public class PostController { private final PostService postService; + private final CommentService commentService; /** * 게시글 수정 @@ -27,25 +34,24 @@ public class PostController { */ @PutMapping("/{postId}") public ResponseEntity> updatePost( - // TODO @AuthenticationPrincipal CustomUserDetails user, + @AuthenticationPrincipal CustomUserDetails user, @PathVariable Long postId, @RequestBody @Valid PostRequest.UpdatePost request ) { - postService.updatePost(1L, postId, request); + postService.updatePost(user.getId(), postId, request); return ResponseEntity.ok(ApiResponse.ok()); } /** * 게시글 삭제 * 작성자만 삭제 가능 - * TODO 댓글도 같이 soft delete */ @DeleteMapping("/{postId}") public ResponseEntity> deletePost( - // TODO @AuthenticationPrincipal CustomUserDetails user, + @AuthenticationPrincipal CustomUserDetails user, @PathVariable Long postId ) { - postService.deletePost(1L, postId); + postService.deletePost(user.getId(), postId); return ResponseEntity.ok(ApiResponse.ok()); } @@ -84,4 +90,36 @@ public ResponseEntity>> getPosts( Page response = postService.getPosts(merchantId, type, pageable); return ResponseEntity.ok(ApiResponse.ok(response)); } + + /** + * 댓글 작성 + * is inactive = false, is deleted = false 상태의 post에만 댓글을 작성할 수 있음 + * + * 부모 댓글이 삭제되어도 대댓글 작성 허용 + */ + @PostMapping("/{postId}/comments") + public ResponseEntity> createComment( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long postId, + @RequestBody @Valid CommentRequest.CreateComment request + ) { + Long commentId = commentService.createComment(user.getId(), postId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(commentId)); + } + + /** + * 게시글 댓글 조회 + * + * 부모 댓글로 페이지네이션 + * 주의) 부모 댓글이 삭제되어도 자식 댓글이 남아있으면 [삭제된 댓글입니다]로 제공 + */ + @GetMapping("/{postId}/comments") + public ResponseEntity> getPostComments( + @PathVariable Long postId, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size + ) { + CommentResponse.CommentList response = commentService.getPostComments(postId, cursorId, size); + return ResponseEntity.ok(ApiResponse.ok(response)); + } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/post/entity/Post.java b/src/main/java/com/payper/server/post/entity/Post.java index 56eac08..604abaa 100644 --- a/src/main/java/com/payper/server/post/entity/Post.java +++ b/src/main/java/com/payper/server/post/entity/Post.java @@ -110,10 +110,10 @@ public void increaseCommentCount() { } /** - * 댓글 삭제 + * 댓글 감소 */ public void decreaseCommentCount() { - this.commentCount--; + this.commentCount = Math.max(0, this.commentCount - 1); } /** @@ -127,6 +127,7 @@ public void delete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); + this.commentCount = 0; } /** @@ -134,9 +135,13 @@ public void delete() { */ public void inactivate() { this.isInactive = true; - this.inactiveAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); +// this.inactiveAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + this.inactiveAt = LocalDateTime.now(); } + /** + * 게시글 생성 + */ public static Post create(User author, Merchant merchant, PostType type, String title, String content) { return Post.builder() .author(author) @@ -147,8 +152,18 @@ public static Post create(User author, Merchant merchant, PostType type, String .build(); } + /** + * 게시글 수정 + */ public void update(String title, String content) { this.title = title; this.content = content; } + + /** + * 댓글을 달 수 있는 게시글인지 체크 + */ + public boolean isCommentable() { + return !this.isDeleted && !this.isInactive; + } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/post/repository/PostRepository.java b/src/main/java/com/payper/server/post/repository/PostRepository.java index 0976fb5..9e438f0 100644 --- a/src/main/java/com/payper/server/post/repository/PostRepository.java +++ b/src/main/java/com/payper/server/post/repository/PostRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -16,6 +17,19 @@ public interface PostRepository extends JpaRepository { Optional findByIdAndIsDeletedFalse(Long id); + boolean existsByIdAndIsDeletedFalse(Long id); + + @Modifying + @Query(""" + update Post p + set p.commentCount = case + when p.commentCount < :count then 0 + else p.commentCount - :count + end + where p.id = :postId + """) + void decreaseCommentCount(@Param("postId") Long postId, @Param("count") long count); + @Query( value = """ select p from Post p diff --git a/src/main/java/com/payper/server/post/service/PostService.java b/src/main/java/com/payper/server/post/service/PostService.java index a414bbd..e48064e 100644 --- a/src/main/java/com/payper/server/post/service/PostService.java +++ b/src/main/java/com/payper/server/post/service/PostService.java @@ -1,5 +1,6 @@ package com.payper.server.post.service; +import com.payper.server.comment.service.CommentService; import com.payper.server.global.exception.ApiException; import com.payper.server.global.response.ErrorCode; import com.payper.server.merchant.entity.Merchant; @@ -26,6 +27,7 @@ public class PostService { private final UserRepository userRepository; private final PostRepository postRepository; private final MerchantRepository merchantRepository; + private final CommentService commentService; /** * 게시글 작성 @@ -53,13 +55,13 @@ public Long createPost(Long userId, Long merchantId, PostRequest.CreatePost requ */ @Transactional public void updatePost(Long userId, Long postId, PostRequest.UpdatePost request) { - // 게시글 조회 - Post post = postRepository.findById(postId) + // 게시글 조회 및 삭제 여부 체크 + Post post = postRepository.findByIdAndIsDeletedFalse(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); // 게시글 수정 권한 조회 if(!userId.equals(post.getAuthor().getId())) { - throw new ApiException(ErrorCode.NOT_POSTING_USER); + throw new ApiException(ErrorCode.NOT_POST_AUTHOR); } // 게시글 수정 @@ -79,15 +81,19 @@ public void deletePost(Long userId, Long postId) { // 게시글 삭제 권한 조회 if(!userId.equals(post.getAuthor().getId())) { - throw new ApiException(ErrorCode.NOT_POSTING_USER); + throw new ApiException(ErrorCode.NOT_POST_AUTHOR); } // 게시글 삭제 post.delete(); log.info("게시글 삭제 완료 - postId: {}", post.getId()); - // TODO 댓글 삭제 (대댓글도 삭제) -// commentRepository.softDeleteByPostId(postId); + // 댓글 삭제 - 댓글 삭제에 실패해도 게시물 삭제는 진행되어야 하므로 try-catch로 묶음 + try { + commentService.softDeleteByPostId(postId); + } catch (Exception e) { + log.error("댓글 삭제 실패 postId={}", postId); + } } /** diff --git a/src/main/java/com/payper/server/security/CustomUserDetails.java b/src/main/java/com/payper/server/security/CustomUserDetails.java index 36aa84c..0137f18 100644 --- a/src/main/java/com/payper/server/security/CustomUserDetails.java +++ b/src/main/java/com/payper/server/security/CustomUserDetails.java @@ -33,6 +33,10 @@ public String getUsername() { return user.getId().toString(); } + public Long getId() { + return user.getId(); + } + @Override public boolean isEnabled() { return user.isActive(); diff --git a/src/main/java/com/payper/server/security/SecurityConfig.java b/src/main/java/com/payper/server/security/SecurityConfig.java index 828b7b7..e228dd1 100644 --- a/src/main/java/com/payper/server/security/SecurityConfig.java +++ b/src/main/java/com/payper/server/security/SecurityConfig.java @@ -48,11 +48,27 @@ void init() { requestMatcher.matcher(HttpMethod.GET, "/swagger-ui/**"), requestMatcher.matcher(HttpMethod.GET, "/v3/api-docs/**"), requestMatcher.matcher(HttpMethod.GET, "/favicon.ico"), - requestMatcher.matcher("/auth/**") + requestMatcher.matcher("/auth/**"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/posts/**"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/comments/*/replies") ); + // 인증이 필요한 요청 authenticatedRequestMatcher = new OrRequestMatcher( //requestMatcher.matcher("/**"), - requestMatcher.matcher(HttpMethod.GET, "/me") + requestMatcher.matcher(HttpMethod.GET, "/me"), + + // 댓글 관련 + requestMatcher.matcher(HttpMethod.PUT, "/api/v1/comments/**"), + requestMatcher.matcher(HttpMethod.DELETE, "/api/v1/comments/**"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/comments/me"), + + // 게시물 관련 + requestMatcher.matcher(HttpMethod.POST, "/api/v1/posts/**"), + requestMatcher.matcher(HttpMethod.PUT, "/api/v1/posts/**"), + requestMatcher.matcher(HttpMethod.DELETE, "/api/v1/posts/**"), + + // 가맹점 관련 + requestMatcher.matcher(HttpMethod.POST, "/api/v1/merchants/**") ); adminRequestMatcher = new OrRequestMatcher( requestMatcher.matcher(HttpMethod.GET, "/admin/**")