diff --git a/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java b/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java index 3992981c..26b4be8e 100644 --- a/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java +++ b/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java @@ -154,7 +154,7 @@ public CreateCommentResponse createComment(Long articleId, CreateCommentRequest Article article = articleRepository.findById(articleId) .orElseThrow(NotFoundArticleException::new); CommentEntity savedComment = commentRepository.save( - new CommentEntity(request.getContent(), article) + new CommentEntity(request.getContent(), article, member) ); articleParticipantRepository.save(new ArticleParticipant(article, member)); diff --git a/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java b/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java new file mode 100644 index 00000000..8910e1cf --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java @@ -0,0 +1,63 @@ +package com.numberone.backend.domain.comment.controller; + +import com.numberone.backend.domain.comment.dto.request.CreateChildCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateChildCommentResponse; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@Slf4j +@RequestMapping("api/comments") +@RequiredArgsConstructor +@RestController +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "대댓글 작성 API", description = """ + comment-id 는 부모 댓글의 id 입니다. + article-id 와 comment-id 는 모두 path variable 으로 보내주세요! + """) + @PostMapping("{article-id}/{comment-id}") + public ResponseEntity createChildComment( + @PathVariable("article-id") Long articleId, + @PathVariable("comment-id") Long commentId, + @RequestBody @Valid CreateChildCommentRequest request) { + CreateChildCommentResponse response = commentService.createChildComment(articleId, commentId, request); + return ResponseEntity.created( + URI.create(String.format("/comments/%s/%s", articleId, commentId))) + .body(response); + } + + @Operation(summary = "해당 게시물에 달린 댓긂을 모두 조회하는 API 입니다.", description = """ + 해당 게시물에 달린 댓글을 계층 형태로 조회합니다. + + - Long commentId : 댓글 아이디 + - Long parentCommentId : 부모 댓글의 야이디 (nullable) + - List childComments = new ArrayList<>() : 대댓글 리스트 + - Integer likeCount : 해당 댓글의 좋아요 개수 + - LocalDateTime createdAt : 해당 댓글의 생성 시각 + - LocalDateTime modifiedAt : 해당 댓글의 마지막 수정 시각 + - String content : 해당 댓글의 내용 + - Long authorId : 해당 댓글의 작성자 아이디 + - String authorNickName : 해당 댓글의 작성자 닉네임 + - String authorProfileImageUrl : 해당 댓글 작성자의 프로필 사진 url + + 댓글 작성자가 추후에 탈퇴하는 경우를 고려했는데, + authorNickName 이 "알수없는 사용자" 로 변경되어 내려갑니다..! + """) + @GetMapping("{article-id}") + public ResponseEntity> getCommentsByArticle(@PathVariable("article-id") Long articleId){ + List response = commentService.getCommentsByArticle(articleId); + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java new file mode 100644 index 00000000..5b765e43 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java @@ -0,0 +1,15 @@ +package com.numberone.backend.domain.comment.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateChildCommentRequest { + @NotNull + private String content; + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java new file mode 100644 index 00000000..80d65519 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java @@ -0,0 +1,25 @@ +package com.numberone.backend.domain.comment.dto.response; + +import com.numberone.backend.domain.comment.entity.CommentEntity; +import lombok.*; + +import java.time.LocalDateTime; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateChildCommentResponse { + + private LocalDateTime createdAt; + private Long commentId; + + public static CreateChildCommentResponse of (CommentEntity comment){ + return CreateChildCommentResponse.builder() + .createdAt(comment.getCreatedAt()) + .commentId(comment.getId()) + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java index d6c72e13..d1cbf278 100644 --- a/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java @@ -20,4 +20,5 @@ public static CreateCommentResponse of (CommentEntity comment){ .commentId(comment.getId()) .build(); } + } diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java new file mode 100644 index 00000000..4fe99af3 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java @@ -0,0 +1,62 @@ +package com.numberone.backend.domain.comment.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.member.entity.Member; +import com.querydsl.core.annotations.QueryProjection; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GetCommentDto { + + private Long commentId; + private Long parentCommentId; + private List childComments = new ArrayList<>(); + private Integer likeCount; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime createdAt; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime modifiedAt; + private String content; + private Long authorId; + private String authorNickName; + private String authorProfileImageUrl; + + + @QueryProjection + public GetCommentDto(CommentEntity comment){ + if(!Objects.isNull(comment.getParent())){ + this.parentCommentId = comment.getParent().getId(); + } + this.commentId = comment.getId(); + this.createdAt = comment.getCreatedAt(); + this.modifiedAt = comment.getModifiedAt(); + this.content = comment.getContent(); + this.authorId = comment.getAuthorId(); + this.likeCount = comment.getLikeCount(); + } + + public void updateCommentInfo(Optional author){ + author.ifPresentOrElse( + a -> { + this.authorNickName = a.getNickName(); + this.authorProfileImageUrl = a.getProfileImageUrl(); + }, + () -> { + this.authorNickName = "알 수 없는 사용자"; + } + ); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java b/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java index bb2b3891..7961af6f 100644 --- a/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java +++ b/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java @@ -2,12 +2,17 @@ import com.numberone.backend.config.basetime.BaseTimeEntity; import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.member.entity.Member; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +import java.util.ArrayList; +import java.util.List; + @Comment("동네생활 댓글 정보") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @@ -32,10 +37,25 @@ public class CommentEntity extends BaseTimeEntity { @Comment("댓글 내용") private String content; - public CommentEntity(String content, Article article){ + @Comment("작성자 아이디") + private Long authorId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private CommentEntity parent; + + @OneToMany(mappedBy = "parent", orphanRemoval = true) + private List childs = new ArrayList<>(); + + public CommentEntity(String content, Article article, Member author){ this.depth = 0; this.content = content; this.article = article; + this.authorId = author.getId(); + } + + public void updateParent(CommentEntity parent){ + this.parent = parent; } } diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java b/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java index d86de378..6ffcf463 100644 --- a/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java @@ -1,7 +1,8 @@ package com.numberone.backend.domain.comment.repository; import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.custom.CommentRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; -public interface CommentRepository extends JpaRepository { +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { } diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java new file mode 100644 index 00000000..fe326c39 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.comment.repository.custom; + +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; + +import java.util.List; + +public interface CommentRepositoryCustom { + public List findAllByArticle(Long articleId); + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java new file mode 100644 index 00000000..69331895 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java @@ -0,0 +1,34 @@ +package com.numberone.backend.domain.comment.repository.custom; + +import com.numberone.backend.domain.article.entity.QArticle; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.dto.response.QGetCommentDto; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; + +import java.util.List; + +import static com.numberone.backend.domain.article.entity.QArticle.article; +import static com.numberone.backend.domain.comment.entity.QCommentEntity.commentEntity; + +public class CommentRepositoryCustomImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + public CommentRepositoryCustomImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public List findAllByArticle(Long articleId) { + return queryFactory.select(new QGetCommentDto(commentEntity)) + .from(commentEntity) + .leftJoin(commentEntity.parent) + .fetchJoin() + .where(commentEntity.article.id.eq(articleId)) + .orderBy( + commentEntity.parent.id.asc().nullsFirst(), + commentEntity.createdAt.asc() + ) + .fetch(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java b/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java new file mode 100644 index 00000000..ed8b83d7 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java @@ -0,0 +1,88 @@ +package com.numberone.backend.domain.comment.service; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.repository.ArticleRepository; +import com.numberone.backend.domain.articleparticipant.entity.ArticleParticipant; +import com.numberone.backend.domain.articleparticipant.repository.ArticleParticipantRepository; +import com.numberone.backend.domain.comment.dto.request.CreateChildCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateChildCommentResponse; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.CommentRepository; +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.token.util.SecurityContextProvider; +import com.numberone.backend.exception.notfound.NotFoundArticleException; +import com.numberone.backend.exception.notfound.NotFoundCommentException; +import com.numberone.backend.exception.notfound.NotFoundMemberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class CommentService { + + private final CommentRepository commentRepository; + private final ArticleRepository articleRepository; + private final ArticleParticipantRepository articleParticipantRepository; + private final MemberRepository memberRepository; + + @Transactional + public CreateChildCommentResponse createChildComment( + Long articleId, + Long parentCommentId, + CreateChildCommentRequest request) { + + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + CommentEntity parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(NotFoundCommentException::new); + + CommentEntity childComment = commentRepository.save(new CommentEntity(request.getContent(), article, member)); + childComment.updateParent(parentComment); + + articleParticipantRepository.save(new ArticleParticipant(article, member)); + + return CreateChildCommentResponse.of(childComment); + } + + public List getCommentsByArticle(Long articleId) { + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + List comments = commentRepository.findAllByArticle(article.getId()); + + // 계층 구조로 변환 (추후 리팩토링 필요) + List result = new ArrayList<>(); + Map map = new HashMap<>(); + comments.forEach( + comment -> { + CommentEntity commentEntity = commentRepository.findById(comment.getCommentId()) + .orElseThrow(NotFoundCommentException::new); + Optional author = memberRepository.findById(commentEntity.getAuthorId()); + comment.updateCommentInfo(author); + + map.put(comment.getCommentId(), comment); + + if (comment.getParentCommentId() != null){ + GetCommentDto parentComment = map.get(comment.getParentCommentId()); + List childComments = parentComment.getChildComments(); + childComments.add(comment); + } else { + result.add(comment); + } + } + ); + + return result; + } + +} diff --git a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java index f8c3c709..cf55645d 100644 --- a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java +++ b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java @@ -42,6 +42,9 @@ public enum CustomExceptionContext implements ExceptionContext { // article image 관련 예외 NOT_FOUND_ARTICLE_IMAGE("해당 이미지를 찾을 수 없습니다.", 9000), + + // comment 관련 예외 + NOT_FOUND_COMMENT("해당 댓글을 찾을 수 없습니다.", 10000), ; private final String message; diff --git a/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java b/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java new file mode 100644 index 00000000..c5f0e8db --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.notfound; + +import static com.numberone.backend.exception.context.CustomExceptionContext.NOT_FOUND_COMMENT; + +public class NotFoundCommentException extends NotFoundException { + public NotFoundCommentException(){ + super(NOT_FOUND_COMMENT); + } +}