diff --git a/src/main/java/chzzk/grassdiary/domain/comment/controller/CommentController.java b/src/main/java/chzzk/grassdiary/domain/comment/controller/CommentController.java new file mode 100644 index 00000000..e7e644dc --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/controller/CommentController.java @@ -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 findAll( + Pageable pageable, + @PathVariable(name = "diaryId") Long diaryId + ) { + return commentService.findAll(pageable, diaryId); + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentDeleteResponseDTO.java b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentDeleteResponseDTO.java new file mode 100644 index 00000000..2ef2488b --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentDeleteResponseDTO.java @@ -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() + ); + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentResponseDTO.java b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentResponseDTO.java new file mode 100644 index 00000000..4912a875 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentResponseDTO.java @@ -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 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()) + ); + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentSaveRequestDTO.java b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentSaveRequestDTO.java new file mode 100644 index 00000000..fe590d34 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentSaveRequestDTO.java @@ -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(); + } +} + diff --git a/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentUpdateRequestDTO.java b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentUpdateRequestDTO.java new file mode 100644 index 00000000..3aec6824 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/dto/CommentUpdateRequestDTO.java @@ -0,0 +1,6 @@ +package chzzk.grassdiary.domain.comment.dto; + +public record CommentUpdateRequestDTO( + String content +) { +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/entity/Comment.java b/src/main/java/chzzk/grassdiary/domain/comment/entity/Comment.java new file mode 100644 index 00000000..97221884 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/entity/Comment.java @@ -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 childComments = new ArrayList<>(); + +// @OneToMany(mappedBy = "comment", cascade = CascadeType.ALL) +// private List 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; + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java new file mode 100644 index 00000000..a72932a4 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentDAO.java @@ -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 { + @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 findAllByDiaryId(Long diaryId, Pageable pageable); +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentLike.java b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentLike.java new file mode 100644 index 00000000..002fa108 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/entity/CommentLike.java @@ -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 { + +} diff --git a/src/main/java/chzzk/grassdiary/domain/comment/service/CommentService.java b/src/main/java/chzzk/grassdiary/domain/comment/service/CommentService.java new file mode 100644 index 00000000..e902d9f1 --- /dev/null +++ b/src/main/java/chzzk/grassdiary/domain/comment/service/CommentService.java @@ -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 findAll(Pageable pageable, Long diaryId) { + Diary diary = getDiaryById(diaryId); + List comments = commentDAO.findAllByDiaryId(diaryId, pageable); + + List hierarchicalComments = convertHierarchy(comments); + + return hierarchicalComments.stream() + .map(this::mapToDTO) + .collect(Collectors.toList()); + } + + private List convertHierarchy(List comments) { + Map map = new HashMap<>(); + List 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); + } + } +} diff --git a/src/main/java/chzzk/grassdiary/domain/diary/entity/Diary.java b/src/main/java/chzzk/grassdiary/domain/diary/entity/Diary.java index 3747a572..e4ce5e7c 100644 --- a/src/main/java/chzzk/grassdiary/domain/diary/entity/Diary.java +++ b/src/main/java/chzzk/grassdiary/domain/diary/entity/Diary.java @@ -3,6 +3,7 @@ import chzzk.grassdiary.domain.base.BaseTimeEntity; import chzzk.grassdiary.domain.color.ConditionLevel; import chzzk.grassdiary.domain.member.entity.Member; +import chzzk.grassdiary.domain.comment.entity.Comment; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -44,6 +45,9 @@ public class Diary extends BaseTimeEntity { @ColumnDefault("true") private Boolean isPrivate; + @OneToMany(mappedBy = "diary") + private List comments = new ArrayList<>(); + @OneToMany(mappedBy = "diary") private List diaryLikes = new ArrayList<>(); diff --git a/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java b/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java index 29f1dba8..9cce02a7 100644 --- a/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java +++ b/src/main/java/chzzk/grassdiary/global/common/response/ClientErrorCode.java @@ -16,8 +16,11 @@ public enum ClientErrorCode implements ErrorCodeModel { DIARY_NOT_FOUND_ERR(404, "DIARY_NOT_FOUND_ERR", "요청하신 다이어리를 찾을 수 없습니다."), DIARY_LIKE_NOT_FOUND(404, "DIARY_LIKE_NOT_FOUND", "해당 다이어리에 좋아요를 누르지 않았습니다."), DIARY_LIKE_ALREADY_EXISTS(409, "DIARY_LIKE_ALREADY_EXISTS", "다이어리에 좋아요를 이미 눌렀습니다."), + COMMENT_NOT_FOUND_ERR(404, "COMMENT_NOT_FOUND_ERR", "요청하신 댓글을 찾을 수 없습니다."), INVALID_IMAGE_FORMAT(400, "INVALID_IMAGE_FORMAT", "허용되지 않는 파일 형식입니다."), IMAGE_FILE_EMPTY(400, "IMAGE_FILE_EMPTY", "이미지 파일이 비어 있습니다."), + AUTHOR_MISMATCH_ERR(403, "AUTHOR_MISMATCH_ERR", "작성자가 아닙니다."), + COMMENT_ALREADY_DELETED_ERR(400, "COMMENT_ALREADY_DELETED_ERR", "이미 삭제된 댓글입니다."), VALIDATION_ERR(400, "VALIDATION_ERR", "잘못된 입력입니다. 올바른 값을 입력해주세요."), PERMISSION_ERR(403, "PERMISSION_ERR", "접근 권한이 없습니다. 관리자에게 문의하세요."), diff --git a/src/test/java/chzzk/grassdiary/domain/auth/jwt/JwtTokenProviderTest.java b/src/test/java/chzzk/grassdiary/domain/auth/jwt/JwtTokenProviderTest.java index c69d9c4f..cd7fcc1c 100644 --- a/src/test/java/chzzk/grassdiary/domain/auth/jwt/JwtTokenProviderTest.java +++ b/src/test/java/chzzk/grassdiary/domain/auth/jwt/JwtTokenProviderTest.java @@ -33,7 +33,47 @@ // AuthMemberPayload payload = AuthMemberPayload.from(1L); // String accessToken = jwtTokenProvider.generateAccessToken(payload); // -// // when +// // whenpackage chzzk.grassdiary.domain.auth.jwt; +//// +////import static org.assertj.core.api.Assertions.assertThat; +////import static org.assertj.core.api.Assertions.assertThatCode; +//// +////import chzzk.grassdiary.auth.jwt.JwtTokenProvider; +////import chzzk.grassdiary.auth.service.dto.AuthMemberPayload; +////import org.junit.jupiter.api.Test; +////import org.springframework.beans.factory.annotation.Autowired; +////import org.springframework.boot.test.context.SpringBootTest; +//// +////@SpringBootTest +////class JwtTokenProviderTest { +//// @Autowired +//// private JwtTokenProvider jwtTokenProvider; +//// +//// @Test +//// public void JWT_액세스_토큰_생성() { +//// // given +//// AuthMemberPayload payload = AuthMemberPayload.from(1L); +//// +//// // when +//// String accessToken = jwtTokenProvider.generateAccessToken(payload); +//// +//// // then +//// System.out.println("accessToken = " + accessToken); +//// assertThat(accessToken).isNotNull(); +//// } +//// +//// @Test +//// public void JWT_액세스_토큰_검증() { +//// // given +//// AuthMemberPayload payload = AuthMemberPayload.from(1L); +//// String accessToken = jwtTokenProvider.generateAccessToken(payload); +//// +//// // when +//// // then +//// assertThatCode(() -> jwtTokenProvider.extractIdFromAccessToken(accessToken)) +//// .doesNotThrowAnyException(); +//// } +////} // // then // assertThatCode(() -> jwtTokenProvider.extractIdFromAccessToken(accessToken)) // .doesNotThrowAnyException();