Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Comment) : 댓글 기능 추가 #102

Merged
merged 15 commits into from
Jul 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package chzzk.grassdiary.domain.comment.controller;

import chzzk.grassdiary.domain.comment.dto.CommentDeleteResponseDTO;
import chzzk.grassdiary.domain.comment.dto.CommentResponseDTO;
import chzzk.grassdiary.domain.comment.dto.CommentSaveRequestDTO;
import chzzk.grassdiary.domain.comment.dto.CommentUpdateRequestDTO;
import chzzk.grassdiary.domain.comment.service.CommentService;
import chzzk.grassdiary.global.auth.common.AuthenticatedMember;
import chzzk.grassdiary.global.auth.service.dto.AuthMemberPayload;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequiredArgsConstructor
@RequestMapping("/api/comment")
@Tag(name = "댓글 컨트롤러")
public class CommentController {
private final CommentService commentService;

@PostMapping("/{diaryId}")
public CommentResponseDTO save(
@PathVariable(name = "diaryId") Long diaryId,
@RequestBody CommentSaveRequestDTO requestDTO,
@AuthenticatedMember AuthMemberPayload payload
) {
return commentService.save(diaryId, requestDTO, payload.id());
}

@PatchMapping("/{commentId}")
public CommentResponseDTO update(
@PathVariable(name = "commentId") Long commentId,
@RequestBody CommentUpdateRequestDTO requestDTO,
@AuthenticatedMember AuthMemberPayload payload
) {
return commentService.update(commentId, requestDTO, payload.id());
}

@PatchMapping("/{commentId}/delete")
public CommentDeleteResponseDTO delete(
@PathVariable(name = "commentId") Long commentId,
@AuthenticatedMember AuthMemberPayload payload
) {
return commentService.delete(commentId, payload.id());
}

// 모든 댓글 검색
@GetMapping("/{diaryId}")
public List<CommentResponseDTO> findAll(
Pageable pageable,
@PathVariable(name = "diaryId") Long diaryId
) {
return commentService.findAll(pageable, diaryId);
}
Comment on lines +58 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pageable을 사용할 때 default 개수를 설정 해 주는 것은 어떨까요? 몇 개 정도가 적당하다고 생각히시는지 궁금해요. 5개 정도면 괜찮으려나요?

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package chzzk.grassdiary.domain.comment.dto;

import chzzk.grassdiary.domain.comment.entity.Comment;

public record CommentDeleteResponseDTO(
boolean deleted
) {
public static CommentDeleteResponseDTO from(Comment comment) {
return new CommentDeleteResponseDTO(
comment.isDeleted()
);
}
}
Comment on lines +5 to +13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 리뷰 중 deleteResponseDTO 클래스에 대해 ... ! 의견 남겨봅니다.
현재 구현된 내용을 보면서, 이 클래스가 반드시 필요한지에 대해 고민해 보았습니다.

해당 클래스에서 isDeleted만 들어가는 거라면 deleteResponseDTO 클래스 보다는 CommentResponseDTO를 사용하는 것이 코드의 단순화와 유지 보수 측면에서 조금 더 효율적일 수 있다는 생각이 듭니다.

프론트에서 응답을 받았을 때 isDeleted만 받았을 때는 어떤 댓글에 대한 삭제를 했는지를 나타낼 수 없지만, CommentResponseDTO를 사용하면 기존 코드를 재사용 하면서도 프론트에서 좀 더 자세하게 알아볼 수 있을 것 같아요.
어떻게 생각하시는지 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

불필요한 데이터 전송을 최소화하고자 따로 만들었는데, 유지 보수 측면을 생각해보면 CommentResponseDTO로 통일 시키는 것도 괜찮아 보이네요.
프론트에서 댓글을 삭제했을 때 CommentResponseDTO로 응답을 받아도 null값과 isDeleted의 true값만을 받아서 어떤 댓글에 대한 삭제를 했는지 알기는 어려울 것 같습니다. 그렇다면 CommentDeletedDTO를 활용하여 삭제된 댓글의 id값과 댓글 내용을 같이 보내주어 프론트측에서 어떤 댓글이 삭제되었는지 명확히 알 수 있도록 하는 방법도 괜찮아 보이는데 어떻게 생각하시는지 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가적으로, 예슬님 피드백을 보다보니 CommentResponseDTO에 CommentID값을 같이 보내주도록 추가하는 것이 필수적인 것 같습니다. 감사합니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CommentId가 없었군요? 추가 하는 것 좋습니다 👍👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package chzzk.grassdiary.domain.comment.dto;

import chzzk.grassdiary.domain.comment.entity.Comment;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;

public record CommentResponseDTO(
// 멤버 정보(사진, 아이디), 댓글내용, 작성 시간
// todo: 사진정보 추가
Long commentId,
Long memberId,
String content,
boolean deleted,
String createdDate,
String createdAt,
List<CommentResponseDTO> childComments
) {
public static CommentResponseDTO from(Comment comment) {
return new CommentResponseDTO(
comment.getId(),
comment.getMember().getId(),
comment.getContent(),
comment.isDeleted(),
comment.getCreatedAt().format(DateTimeFormatter.ofPattern("yy년 MM월 dd일")),
comment.getCreatedAt().format(DateTimeFormatter.ofPattern("HH:mm")),
comment.getChildComments().stream().map(CommentResponseDTO::from).collect(Collectors.toList())
);
}

public static CommentResponseDTO fromDeleted(Comment comment) {
return new CommentResponseDTO(
comment.getId(),
null,
null,
true,
null,
null,
comment.getChildComments().stream().map(CommentResponseDTO::from).collect(Collectors.toList())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package chzzk.grassdiary.domain.comment.dto;

import chzzk.grassdiary.domain.comment.entity.Comment;
import chzzk.grassdiary.domain.diary.entity.Diary;
import chzzk.grassdiary.domain.member.entity.Member;

public record CommentSaveRequestDTO(
Long memberId,
Long diaryId,
String content,
Long parentCommentId
) {
public Comment toEntity(Member member, Diary diary, Comment parentComment) {
return Comment.builder()
.member(member)
.diary(diary)
.content(content)
.parentComment(parentComment)
.build();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package chzzk.grassdiary.domain.comment.dto;

public record CommentUpdateRequestDTO(
String content
) {
}
Comment on lines +3 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 클래스도 deleteResponseDTO에 대한 동일한 의견 드립니다.

73 changes: 73 additions & 0 deletions src/main/java/chzzk/grassdiary/domain/comment/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package chzzk.grassdiary.domain.comment.entity;

import chzzk.grassdiary.domain.base.BaseTimeEntity;
import chzzk.grassdiary.domain.diary.entity.Diary;
import chzzk.grassdiary.domain.member.entity.Member;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "diary_id")
private Diary diary;

@Column(columnDefinition = "TEXT")
private String content;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
@JsonIgnore
private Comment parentComment;

@OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL)
private List<Comment> childComments = new ArrayList<>();

// @OneToMany(mappedBy = "comment", cascade = CascadeType.ALL)
// private List<CommentLike> commentLikes = new ArrayList<>();

private boolean deleted;

@Builder
protected Comment(Member member, Diary diary, String content, Comment parentComment) {
this.member = member;
this.diary = diary;
this.content = content;
this.parentComment = parentComment;
this.deleted = false;
}

public void update(String content) {
this.content = content;
}

public void delete() {
this.deleted = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package chzzk.grassdiary.domain.comment.entity;

import java.util.List;
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.Query;

public interface CommentDAO extends JpaRepository<Comment, Long> {
@Query("select c from Comment c join fetch c.member left join fetch c.parentComment where c.diary.id = :diaryId order by c.parentComment.id asc nulls first, c.id asc")
List<Comment> findAllByDiaryId(Long diaryId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package chzzk.grassdiary.domain.comment.entity;

import chzzk.grassdiary.domain.base.BaseCreatedTimeEntity;
import chzzk.grassdiary.domain.base.BaseTimeEntity;

public class CommentLike extends BaseTimeEntity {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package chzzk.grassdiary.domain.comment.service;

import chzzk.grassdiary.domain.comment.dto.CommentDeleteResponseDTO;
import chzzk.grassdiary.domain.comment.dto.CommentSaveRequestDTO;
import chzzk.grassdiary.domain.comment.dto.CommentUpdateRequestDTO;
import chzzk.grassdiary.domain.comment.entity.Comment;
import chzzk.grassdiary.domain.comment.entity.CommentDAO;
import chzzk.grassdiary.domain.diary.entity.Diary;
import chzzk.grassdiary.domain.diary.entity.DiaryDAO;
import chzzk.grassdiary.domain.comment.dto.CommentResponseDTO;
import chzzk.grassdiary.domain.member.entity.Member;
import chzzk.grassdiary.domain.member.entity.MemberDAO;
import chzzk.grassdiary.global.common.error.exception.SystemException;
import chzzk.grassdiary.global.common.response.ClientErrorCode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentDAO commentDAO;
private final DiaryDAO diaryDAO;
private final MemberDAO memberDAO;

@Transactional
public CommentResponseDTO save(Long diaryId, CommentSaveRequestDTO requestDTO, Long logInMemberId) {
Member member = getMemberById(logInMemberId);
Diary diary = getDiaryById(diaryId);
Comment parentComment = getParentCommentById(requestDTO.parentCommentId());
Comment comment = requestDTO.toEntity(member, diary, parentComment);
commentDAO.save(comment);

return CommentResponseDTO.from(comment);
}

@Transactional
public CommentResponseDTO update(Long commentId, CommentUpdateRequestDTO requestDTO, Long logInMemberId) {
Member member = getMemberById(logInMemberId);
Comment comment = getCommentById(commentId);
validateCommentAuthor(member, comment);
comment.update(requestDTO.content());

return CommentResponseDTO.from(comment);
}

@Transactional
public CommentDeleteResponseDTO delete(Long commentId, Long logInMemberId) {
Member member = getMemberById(logInMemberId);
Comment comment = getCommentById(commentId);
validateCommentAuthor(member, comment);
validateNotDeleted(comment);

comment.delete();

return CommentDeleteResponseDTO.from(comment);
}

@Transactional(readOnly = true)
public List<CommentResponseDTO> findAll(Pageable pageable, Long diaryId) {
Diary diary = getDiaryById(diaryId);
List<Comment> comments = commentDAO.findAllByDiaryId(diaryId, pageable);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pageable을 사용하시는데, findAllByDiaryId보다는 findAllByDiaryIdWithPagination은 어떨까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

댓글의 pageable과 같은 경우는 그냥 모두 불러오도록 List로 받도록 했는데, 파라미터를 미처 삭제하지 못했습니다 ㅋㅋㅋ ㅠㅠ
pageable을 사용해서 일정 개수만 불러오도록 하는게 좋을지 고민이 됩니다!


List<Comment> hierarchicalComments = convertHierarchy(comments);

return hierarchicalComments.stream()
.map(this::mapToDTO)
.collect(Collectors.toList());
}
Comment on lines +66 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에서 mapToDTOCommentResponseDTO::from 으로 코드를 재사용 가능할 것 같아 보여요!


private List<Comment> convertHierarchy(List<Comment> comments) {
Map<Long, Comment> map = new HashMap<>();
List<Comment> parentComments = new ArrayList<>();

for (Comment comment : comments) {
if (comment.getParentComment() != null) {
// 부모 댓글이 있는 경우
Comment parentComment = map.get(comment.getParentComment().getId());
if (parentComment != null) {
parentComment.getChildComments().add(comment);
}
} else {
// 부모 댓글이 없는 경우
parentComments.add(comment);
}
map.put(comment.getId(), comment);
}

return parentComments;
}

private CommentResponseDTO mapToDTO(Comment comment) {
if (comment.isDeleted()) {
return CommentResponseDTO.fromDeleted(comment);
}
return CommentResponseDTO.from(comment);
}

private Member getMemberById(Long id) {
return memberDAO.findById(id)
.orElseThrow(() -> new SystemException(ClientErrorCode.MEMBER_NOT_FOUND_ERR));
}

private Diary getDiaryById(Long id) {
return diaryDAO.findById(id)
.orElseThrow(() -> new SystemException(ClientErrorCode.DIARY_NOT_FOUND_ERR));
}

private Comment getParentCommentById(Long id) {
if (id == null) {
return null;
}
return getCommentById(id);
}

private Comment getCommentById(Long id) {
return commentDAO.findById(id)
.orElseThrow(() -> new SystemException(ClientErrorCode.COMMENT_NOT_FOUND_ERR));
}

private void validateCommentAuthor(Member member, Comment comment) {
if (!member.equals(comment.getMember())) {
throw new SystemException(ClientErrorCode.AUTHOR_MISMATCH_ERR);
}
}

private void validateNotDeleted(Comment comment) {
if (comment.isDeleted()) {
throw new SystemException(ClientErrorCode.COMMENT_ALREADY_DELETED_ERR);
}
}
}
Loading