diff --git a/src/main/java/com/leets/xcellentbe/domain/article/domain/Article.java b/src/main/java/com/leets/xcellentbe/domain/article/domain/Article.java index d754c0d..b298052 100644 --- a/src/main/java/com/leets/xcellentbe/domain/article/domain/Article.java +++ b/src/main/java/com/leets/xcellentbe/domain/article/domain/Article.java @@ -4,7 +4,9 @@ import java.util.List; import java.util.UUID; +import com.leets.xcellentbe.domain.articleLike.domain.ArticleLike; import com.leets.xcellentbe.domain.articleMedia.domain.ArticleMedia; +import com.leets.xcellentbe.domain.comment.domain.Comment; import com.leets.xcellentbe.domain.hashtag.domain.Hashtag; import com.leets.xcellentbe.domain.shared.BaseTimeEntity; import com.leets.xcellentbe.domain.shared.DeletedStatus; @@ -61,10 +63,17 @@ public class Article extends BaseTimeEntity { @OneToMany(mappedBy = "article") private List mediaList; - private int viewCnt, repostCnt, likeCnt, commentCnt; + @OneToMany(mappedBy = "article") + private List comments; + + @OneToMany(mappedBy = "article") + private List articleLikes; + + @Column + private int viewCnt; @Builder - private Article(User writer, String content, DeletedStatus deletedStatus) { + private Article(User writer, String content) { this.writer = writer; this.content = content; this.deletedStatus = DeletedStatus.NOT_DELETED; @@ -83,10 +92,23 @@ public static Article createArticle(User writer, String content) { return Article.builder() .writer(writer) .content(content) - .deletedStatus(DeletedStatus.NOT_DELETED) .build(); } + public void addComments(List comments) { + if(this.comments == null){ + this.comments = new ArrayList<>(); + } + this.comments.addAll(comments); + } + + public void addArticleLike(List articleLikes) { + if(this.articleLikes == null){ + this.articleLikes = new ArrayList<>(); + } + this.articleLikes.addAll(articleLikes); + } + public void addRepost(Article rePost) { this.rePost = rePost; } @@ -112,29 +134,4 @@ public void addMedia(List mediaList) { public void updateViewCount() { this.viewCnt++; } - - public void plusRepostCount() { - this.repostCnt++; - } - - public void minusRepostCount() { - this.repostCnt--; - } - - public void plusLikeCount() { - this.likeCnt++; - } - - public void minusLikeCount() { - this.likeCnt--; - } - - public void plusCommentCount() { - this.commentCnt++; - } - - public void minusCommentCount() { - this.commentCnt--; - - } } diff --git a/src/main/java/com/leets/xcellentbe/domain/article/domain/repository/ArticleRepository.java b/src/main/java/com/leets/xcellentbe/domain/article/domain/repository/ArticleRepository.java index 36cc626..ec1c56f 100644 --- a/src/main/java/com/leets/xcellentbe/domain/article/domain/repository/ArticleRepository.java +++ b/src/main/java/com/leets/xcellentbe/domain/article/domain/repository/ArticleRepository.java @@ -7,21 +7,22 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.leets.xcellentbe.domain.article.domain.Article; import com.leets.xcellentbe.domain.article.dto.ArticlesWithMediaDto; import com.leets.xcellentbe.domain.user.domain.User; -import io.lettuce.core.dynamic.annotation.Param; - public interface ArticleRepository extends JpaRepository { @Query("SELECT new com.leets.xcellentbe.domain.article.dto.ArticlesWithMediaDto(p, pm.filePath) FROM Article p LEFT JOIN PostMedia pm ON p.articleId = pm.article.articleId WHERE p.writer = :user") List findPostsByWriter(User user); - @Query("SELECT a FROM Article a ORDER BY a.createdAt DESC") + @Query("SELECT a FROM Article a WHERE a.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED ORDER BY a.createdAt DESC") List
findRecentArticles(Pageable pageable); - @Query("SELECT a FROM Article a WHERE a.createdAt < :cursor ORDER BY a.createdAt DESC") + @Query("SELECT a FROM Article a WHERE a.createdAt < :cursor AND a.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED ORDER BY a.createdAt DESC") List
findRecentArticles(@Param("cursor") LocalDateTime cursor, Pageable pageable); + @Query("SELECT COUNT(a) FROM Article a WHERE a.rePost = :article AND a.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + long countReposts(@Param("article") Article article); } diff --git a/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleResponseDto.java b/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleResponseDto.java index 9600c85..763dd8e 100644 --- a/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleResponseDto.java +++ b/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleResponseDto.java @@ -1,11 +1,14 @@ package com.leets.xcellentbe.domain.article.dto; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import com.leets.xcellentbe.domain.article.domain.Article; import com.leets.xcellentbe.domain.articleMedia.domain.ArticleMedia; +import com.leets.xcellentbe.domain.comment.dto.CommentResponseDto; +import com.leets.xcellentbe.domain.comment.dto.CommentStatsDto; import com.leets.xcellentbe.domain.hashtag.domain.Hashtag; import com.leets.xcellentbe.domain.shared.DeletedStatus; @@ -23,16 +26,17 @@ public class ArticleResponseDto { private List hashtags; private UUID rePostId; private List mediaUrls; + private List comments; private int viewCnt; - private int rePostCnt; - private int likeCnt; - private int commentCnt; + private long rePostCnt; + private long likeCnt; + private long commentCnt; private boolean owner; @Builder private ArticleResponseDto(UUID articleId, Long writerId, String content, DeletedStatus deletedStatus, - List hashtags, UUID rePostId, List mediaUrls, int viewCnt, - int rePostCnt, int likeCnt, int commentCnt, boolean owner) { + List hashtags, UUID rePostId, List mediaUrls, List comments, + int viewCnt, long rePostCnt, long likeCnt, long commentCnt, boolean owner) { this.articleId = articleId; this.writerId = writerId; this.content = content; @@ -45,9 +49,10 @@ private ArticleResponseDto(UUID articleId, Long writerId, String content, Delete this.likeCnt = likeCnt; this.commentCnt = commentCnt; this.owner = owner; + this.comments = comments; } - public static ArticleResponseDto from(Article article, boolean isOwner) { + public static ArticleResponseDto from(Article article, boolean isOwner, ArticleStatsDto stats, Map replyStatsMap) { return ArticleResponseDto.builder() .articleId(article.getArticleId()) .content(article.getContent()) @@ -62,10 +67,43 @@ public static ArticleResponseDto from(Article article, boolean isOwner) { .stream() .map(ArticleMedia::getFilePath) // 이미지 URL로 매핑 .collect(Collectors.toList()) : null) + .comments(article.getComments() != null ? article.getComments() + .stream() + .filter(comment -> comment != null && comment.getDeletedStatus() == DeletedStatus.NOT_DELETED) // null 및 삭제된 댓글 필터링 + .map(comment -> { + CommentStatsDto commentStats = replyStatsMap.getOrDefault(comment.getCommentId(), CommentStatsDto.from(0, 0)); + boolean isCommentOwner = comment.getWriter().getUserId().equals(article.getWriter().getUserId()); + return CommentResponseDto.from(comment, isCommentOwner, commentStats, replyStatsMap, 1); // 깊이 1로 제한 + }) + .collect(Collectors.toList()) : null) + .viewCnt(article.getViewCnt()) + .rePostCnt(stats.getRepostCnt()) + .likeCnt(stats.getLikeCnt()) + .commentCnt(stats.getCommentCnt()) + .owner(isOwner) + .build(); + } + + public static ArticleResponseDto fromWithoutComments(Article article, boolean isOwner, ArticleStatsDto stats) { + return ArticleResponseDto.builder() + .articleId(article.getArticleId()) + .content(article.getContent()) + .deletedStatus(article.getDeletedStatus()) + .writerId(article.getWriter().getUserId()) + .hashtags(article.getHashtags() != null ? article.getHashtags() + .stream() + .map(Hashtag::getContent) + .collect(Collectors.toList()) : null) + .rePostId(article.getRePost() != null ? article.getRePost().getArticleId() : null) + .mediaUrls(article.getMediaList() != null ? article.getMediaList() + .stream() + .map(ArticleMedia::getFilePath) + .collect(Collectors.toList()) : null) + .comments(null) // 전체 조회 시 댓글 정보 제외 .viewCnt(article.getViewCnt()) - .rePostCnt(article.getRepostCnt()) - .likeCnt(article.getLikeCnt()) - .commentCnt(article.getCommentCnt()) + .rePostCnt(stats.getRepostCnt()) + .likeCnt(stats.getLikeCnt()) + .commentCnt(stats.getCommentCnt()) .owner(isOwner) .build(); } diff --git a/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleStatsDto.java b/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleStatsDto.java new file mode 100644 index 0000000..4a0c848 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/article/dto/ArticleStatsDto.java @@ -0,0 +1,28 @@ +package com.leets.xcellentbe.domain.article.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ArticleStatsDto { + private long likeCnt; + private long commentCnt; + private long repostCnt; + + @Builder + private ArticleStatsDto(long likeCnt, long commentCnt, long repostCnt) { + this.likeCnt = likeCnt; + this.commentCnt = commentCnt; + this.repostCnt = repostCnt; + } + + public static ArticleStatsDto from(long likeCnt, long commentCnt, long repostCnt) { + return ArticleStatsDto.builder() + .likeCnt(likeCnt) + .commentCnt(commentCnt) + .repostCnt(repostCnt) + .build(); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/article/service/ArticleService.java b/src/main/java/com/leets/xcellentbe/domain/article/service/ArticleService.java index 1d4b4fd..130632c 100644 --- a/src/main/java/com/leets/xcellentbe/domain/article/service/ArticleService.java +++ b/src/main/java/com/leets/xcellentbe/domain/article/service/ArticleService.java @@ -19,13 +19,18 @@ import com.leets.xcellentbe.domain.article.dto.ArticleCreateRequestDto; import com.leets.xcellentbe.domain.article.dto.ArticleCreateResponseDto; import com.leets.xcellentbe.domain.article.dto.ArticleResponseDto; +import com.leets.xcellentbe.domain.article.dto.ArticleStatsDto; import com.leets.xcellentbe.domain.article.dto.ArticlesResponseDto; import com.leets.xcellentbe.domain.article.dto.ArticlesWithMediaDto; import com.leets.xcellentbe.domain.article.exception.ArticleNotFoundException; -import com.leets.xcellentbe.domain.article.exception.DeleteForbiddenException; +import com.leets.xcellentbe.domain.articleLike.domain.repository.ArticleLikeRepository; +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.comment.dto.CommentStatsDto; +import com.leets.xcellentbe.domain.commentLike.domain.repository.CommentLikeRepository; +import com.leets.xcellentbe.global.error.exception.custom.DeleteForbiddenException; import com.leets.xcellentbe.domain.articleMedia.domain.ArticleMedia; import com.leets.xcellentbe.domain.articleMedia.domain.repository.ArticleMediaRepository; -import com.leets.xcellentbe.domain.articleMedia.exception.ArticleMediaNotFoundException; +import com.leets.xcellentbe.domain.comment.domain.repository.CommentRepository; import com.leets.xcellentbe.domain.hashtag.HashtagService.HashtagService; import com.leets.xcellentbe.domain.hashtag.domain.Hashtag; import com.leets.xcellentbe.domain.user.domain.User; @@ -43,6 +48,9 @@ public class ArticleService { private final ArticleRepository articleRepository; private final ArticleMediaRepository articleMediaRepository; private final UserRepository userRepository; + private final CommentRepository commentRepository; + private final ArticleLikeRepository articleLikeRepository; + private final CommentLikeRepository commentLikeRepository; private final HashtagService hashtagService; private final S3UploadMediaService s3UploadMediaService; private final JwtService jwtService; @@ -149,15 +157,21 @@ public ArticleResponseDto getArticle(HttpServletRequest request, UUID articleId) Article targetArticle = articleRepository.findById(articleId) .orElseThrow(ArticleNotFoundException::new); - - List mediaList = articleMediaRepository.findByArticle_ArticleId(targetArticle.getArticleId()); - if (mediaList.isEmpty()) { - throw new ArticleMediaNotFoundException(); - } + ArticleStatsDto stats = findArticleStats(targetArticle); targetArticle.updateViewCount(); boolean isOwner = targetArticle.getWriter().getUserId().equals(user.getUserId()); - return ArticleResponseDto.from(targetArticle, isOwner); + List comments = commentRepository.findAllByArticleAndNotDeleted(targetArticle); + Map replyStatsMap = comments.stream() + .collect(Collectors.toMap( + Comment::getCommentId, + reply -> { + long likeCount = commentLikeRepository.countLikesByComment(reply); + long replyCount = commentRepository.countRepliesByComment(reply); + return CommentStatsDto.from(likeCount, replyCount); + } + )); + return ArticleResponseDto.from(targetArticle, isOwner, stats, replyStatsMap); } //게시글 전체 조회 @@ -167,13 +181,17 @@ public List getArticles(HttpServletRequest request, LocalDat Pageable pageable = PageRequest.of(0, size); - List
articles = cursor == null ? + List
articles = (cursor == null) ? articleRepository.findRecentArticles(pageable) : // 처음 로드 시 articleRepository.findRecentArticles(cursor, pageable); return articles .stream() - .map(article -> ArticleResponseDto.from(article, article.getWriter().getUserId().equals(user.getUserId()))) + .map(article -> { + boolean isOwner = article.getWriter().getUserId().equals(user.getUserId()); + ArticleStatsDto stats = findArticleStats(article); + return ArticleResponseDto.fromWithoutComments(article, isOwner, stats); + }) .collect(Collectors.toList()); } @@ -185,8 +203,7 @@ public ArticleCreateResponseDto rePostArticle(HttpServletRequest request, UUID a Article repostedArticle = articleRepository.findById(articleId) .orElseThrow(ArticleNotFoundException::new); Article newArticle = Article.createArticle(writer, repostedArticle.getContent()); - repostedArticle.addRepost(newArticle); - repostedArticle.plusRepostCount(); + newArticle.addRepost(repostedArticle); return ArticleCreateResponseDto.from(articleRepository.save(newArticle)); } @@ -197,12 +214,20 @@ public void deleteRepost(HttpServletRequest request, UUID articleId) { User user = getUser(request); Article targetArticle = articleRepository.findById(articleId) .orElseThrow(ArticleNotFoundException::new); - if (!(targetArticle.getWriter().getUserId().equals(user.getUserId()))) { + // 게시글 작성자와 현재 사용자 일치 여부 확인, 리포스트 ID가 있는 경우에만 삭제 가능 + if ((!targetArticle.getWriter().getUserId().equals(user.getUserId()))||(targetArticle.getRePost() == null)) { throw new DeleteForbiddenException(); - } else { - targetArticle.deleteArticle(); - targetArticle.getRePost().minusRepostCount(); } + // 리포스트 삭제 처리 + targetArticle.deleteArticle(); + articleRepository.save(targetArticle); + } + + public ArticleStatsDto findArticleStats(Article article) { + long likeCount = articleLikeRepository.countLikesByArticleId(article.getArticleId()); + long commentCount = commentRepository.countCommentsByArticle(article); + long repostCount = articleRepository.countReposts(article); + return ArticleStatsDto.from(likeCount, commentCount, repostCount); } //JWT 토큰 기반 사용자 정보 반환 메소드 @@ -214,6 +239,5 @@ private User getUser(HttpServletRequest request) { .orElseThrow(UserNotFoundException::new); return user; - } } diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/controller/ArticleLikeController.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/controller/ArticleLikeController.java index be45a50..230e359 100644 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/controller/ArticleLikeController.java +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/controller/ArticleLikeController.java @@ -1,4 +1,49 @@ package com.leets.xcellentbe.domain.articleLike.controller; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.leets.xcellentbe.domain.articleLike.dto.ArticleLikeResponseDto; +import com.leets.xcellentbe.domain.articleLike.service.ArticleLikeService; +import com.leets.xcellentbe.global.response.GlobalResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/article/{articleId}") +@RequiredArgsConstructor public class ArticleLikeController { + + private final ArticleLikeService articleLikeService; + + @PostMapping("/like") + @Operation(summary = "신규 좋아요 등록", description = "게시글에 좋아요 했습니다.") + public ResponseEntity> articleLike( + HttpServletRequest request, + @PathVariable UUID articleId){ + ArticleLikeResponseDto responseDto = articleLikeService.likeArticle(request, articleId); + + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(responseDto)); + } + + @PatchMapping("/unlike") + @Operation(summary = "좋아요 삭제", description = "게시글에 좋아요를 취소했습니다.") + public ResponseEntity> articleUnLike( + HttpServletRequest request, + @PathVariable UUID articleId){ + articleLikeService.unLike(request, articleId); + + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success()); + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/ArticleLike.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/ArticleLike.java index 293d992..67e85ae 100644 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/ArticleLike.java +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/ArticleLike.java @@ -9,6 +9,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -17,6 +19,7 @@ import jakarta.persistence.ManyToOne; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -27,15 +30,15 @@ public class ArticleLike extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) - private UUID PostLikeId; + private UUID ArticleLikeId; @NotNull - @Column + @Column(columnDefinition = "VARCHAR(30)") + @Enumerated(EnumType.STRING) private DeletedStatus deletedStatus; @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "article_id") private Article article; @@ -43,4 +46,22 @@ public class ArticleLike extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; + + @Builder + private ArticleLike(Article article, User user) { + this.article = article; + this.user = user; + this.deletedStatus = DeletedStatus.NOT_DELETED; + } + + public static ArticleLike create(Article article, User user) { + return ArticleLike.builder() + .article(article) + .user(user) + .build(); + } + + public void deleteArticleLike() { + this.deletedStatus = DeletedStatus.DELETED; + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/repository/ArticleLikeRepository.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/repository/ArticleLikeRepository.java index f8770c1..17a030c 100644 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/repository/ArticleLikeRepository.java +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/domain/repository/ArticleLikeRepository.java @@ -1,10 +1,20 @@ package com.leets.xcellentbe.domain.articleLike.domain.repository; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.leets.xcellentbe.domain.articleLike.domain.ArticleLike; +import com.leets.xcellentbe.domain.shared.DeletedStatus; public interface ArticleLikeRepository extends JpaRepository { + Optional findByArticle_ArticleIdAndUser_UserId(UUID articleId, Long userId); + + @Query("SELECT COUNT(l) FROM ArticleLike l WHERE l.article.articleId = :articleId AND l.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + long countLikesByArticleId(@Param("articleId") UUID articleId); + + Optional findByArticle_ArticleIdAndUser_UserIdAndDeletedStatus(UUID articleId, Long userId, DeletedStatus status); } diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeRequestDto.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeRequestDto.java deleted file mode 100644 index 007a1be..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeRequestDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.leets.xcellentbe.domain.articleLike.dto; - -public class ArticleLikeRequestDto { -} diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeResponseDto.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeResponseDto.java index 5bde7df..fc02d90 100644 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeResponseDto.java +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/dto/ArticleLikeResponseDto.java @@ -1,4 +1,31 @@ package com.leets.xcellentbe.domain.articleLike.dto; +import java.util.UUID; + +import com.leets.xcellentbe.domain.articleLike.domain.ArticleLike; +import com.leets.xcellentbe.domain.shared.DeletedStatus; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor public class ArticleLikeResponseDto { + private UUID articleId; + private Long userId; + private DeletedStatus status; + + @Builder + private ArticleLikeResponseDto(UUID articleId, DeletedStatus status) { + this.articleId = articleId; + this.status = status; + } + + public static ArticleLikeResponseDto from(ArticleLike articleLike) { + return ArticleLikeResponseDto.builder() + .articleId(articleLike.getArticle().getArticleId()) + .status(articleLike.getDeletedStatus()) + .build(); + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/exception/ArticleLikeNotFoundException.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/exception/ArticleLikeNotFoundException.java new file mode 100644 index 0000000..f143585 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/exception/ArticleLikeNotFoundException.java @@ -0,0 +1,10 @@ +package com.leets.xcellentbe.domain.articleLike.exception; + +import com.leets.xcellentbe.global.error.ErrorCode; +import com.leets.xcellentbe.global.error.exception.CommonException; + +public class ArticleLikeNotFoundException extends CommonException { + public ArticleLikeNotFoundException() { + super(ErrorCode.ARTICLE_LIKE_NOT_FOUND); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/articleLike/service/ArticleLikeService.java b/src/main/java/com/leets/xcellentbe/domain/articleLike/service/ArticleLikeService.java index fa59018..8f88f00 100644 --- a/src/main/java/com/leets/xcellentbe/domain/articleLike/service/ArticleLikeService.java +++ b/src/main/java/com/leets/xcellentbe/domain/articleLike/service/ArticleLikeService.java @@ -1,4 +1,75 @@ package com.leets.xcellentbe.domain.articleLike.service; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.leets.xcellentbe.domain.article.domain.Article; +import com.leets.xcellentbe.domain.article.domain.repository.ArticleRepository; +import com.leets.xcellentbe.domain.article.exception.ArticleNotFoundException; +import com.leets.xcellentbe.domain.articleLike.domain.ArticleLike; +import com.leets.xcellentbe.domain.articleLike.domain.repository.ArticleLikeRepository; +import com.leets.xcellentbe.domain.articleLike.dto.ArticleLikeResponseDto; +import com.leets.xcellentbe.domain.articleLike.exception.ArticleLikeNotFoundException; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; +import com.leets.xcellentbe.domain.user.domain.repository.UserRepository; +import com.leets.xcellentbe.domain.user.exception.UserNotFoundException; +import com.leets.xcellentbe.global.auth.jwt.JwtService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor public class ArticleLikeService { + private final ArticleLikeRepository articleLikeRepository; + private final ArticleRepository articleRepository; + private final UserRepository userRepository; + private final JwtService jwtService; + + public ArticleLikeResponseDto likeArticle(HttpServletRequest request, UUID articleId) { + User user = getUser(request); + List articleLikeList = new ArrayList<>(); + Article article = articleRepository.findById(articleId) + .orElseThrow(ArticleNotFoundException::new); + + Optional existingLike = articleLikeRepository.findByArticle_ArticleIdAndUser_UserIdAndDeletedStatus( + articleId, user.getUserId(), DeletedStatus.NOT_DELETED); + + if (existingLike.isPresent()) { + return ArticleLikeResponseDto.from(existingLike.get()); + } + + ArticleLike articleLike = ArticleLike.create(article, user); + articleLikeList.add(articleLike); + article.addArticleLike(articleLikeList); + + return ArticleLikeResponseDto.from(articleLikeRepository.save(articleLike)); + } + + public void unLike(HttpServletRequest request, UUID articleId) { + User user = getUser(request); + ArticleLike articleLike = articleLikeRepository.findByArticle_ArticleIdAndUser_UserIdAndDeletedStatus( + articleId, user.getUserId(), DeletedStatus.NOT_DELETED) + .orElseThrow(ArticleLikeNotFoundException::new); + articleLike.deleteArticleLike(); + articleLikeRepository.save(articleLike); + } + + //JWT 토큰 기반 사용자 정보 반환 메소드 + private User getUser(HttpServletRequest request) { + User user = jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .flatMap(jwtService::extractEmail) + .flatMap(userRepository::findByEmail) + .orElseThrow(UserNotFoundException::new); + + return user; + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/controller/CommentController.java b/src/main/java/com/leets/xcellentbe/domain/comment/controller/CommentController.java index 45be219..7d0f723 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/controller/CommentController.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/controller/CommentController.java @@ -1,4 +1,73 @@ package com.leets.xcellentbe.domain.comment.controller; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.leets.xcellentbe.domain.comment.dto.CommentCreateRequestDto; +import com.leets.xcellentbe.domain.comment.dto.CommentResponseDto; +import com.leets.xcellentbe.domain.comment.service.CommentService; +import com.leets.xcellentbe.global.response.GlobalResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/article/{articleId}") +@RequiredArgsConstructor public class CommentController { + private final CommentService commentService; + + //댓글 작성 + @PostMapping() + @Operation(summary = "댓글 작성", description = "새 댓글을 작성합니다.") + public ResponseEntity> createComment( + HttpServletRequest request, + @RequestBody CommentCreateRequestDto commentCreateRequestDto, + @PathVariable UUID articleId, + @RequestParam(value = "parentCommentId", required = false) UUID parentCommentId){ + commentService.createComment(request, commentCreateRequestDto, articleId, parentCommentId); + return ResponseEntity.status(HttpStatus.OK).body(GlobalResponseDto.success()); + } + + //댓글 삭제(소프트) + @PatchMapping("/{commentId}") + @Operation(summary = "게시글 삭제", description = "게시글을 삭제(상태 변경)합니다.") + public ResponseEntity> deleteComment( + HttpServletRequest request, + @PathVariable UUID commentId){ + commentService.deleteComment(request, commentId); + return ResponseEntity.status(HttpStatus.OK).body(GlobalResponseDto.success()); + } + + //대댓글 삭제(소프트) + @PatchMapping("/{commentId}/{replyId}") + @Operation(summary = "대댓글 삭제", description = "대댓글을 소프트 삭제합니다.") + public ResponseEntity> deleteReply( + HttpServletRequest request, + @PathVariable UUID commentId, + @PathVariable UUID replyId) { + commentService.deleteReply(request, commentId, replyId); + return ResponseEntity.status(HttpStatus.OK).body(GlobalResponseDto.success()); + } + + //댓글 조회 + @GetMapping("/{commentId}") + @Operation(summary = "댓글 조회", description = "해당 ID의 댓글을 조회합니다.") + public ResponseEntity> getComment( + HttpServletRequest request, + @PathVariable UUID commentId){ + CommentResponseDto commentResponseDto = commentService.getComment(request, commentId); + return ResponseEntity.status(HttpStatus.OK).body(GlobalResponseDto.success(commentResponseDto)); + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/domain/Comment.java b/src/main/java/com/leets/xcellentbe/domain/comment/domain/Comment.java index e200613..148b236 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/domain/Comment.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/domain/Comment.java @@ -1,22 +1,29 @@ package com.leets.xcellentbe.domain.comment.domain; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import com.leets.xcellentbe.domain.article.domain.Article; +import com.leets.xcellentbe.domain.commentLike.domain.CommentLike; import com.leets.xcellentbe.domain.shared.BaseTimeEntity; import com.leets.xcellentbe.domain.shared.DeletedStatus; import com.leets.xcellentbe.domain.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; 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 jakarta.validation.constraints.NotNull; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -27,7 +34,7 @@ public class Comment extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) - private UUID CommentId; + private UUID commentId; @NotNull @ManyToOne(fetch = FetchType.LAZY) @@ -39,10 +46,65 @@ public class Comment extends BaseTimeEntity { @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private Article post; + @JoinColumn(name = "article_id") + private Article article; @NotNull - @Column + @Column(columnDefinition = "VARCHAR(30)") + @Enumerated(EnumType.STRING) private DeletedStatus deletedStatus; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; + + @OneToMany(mappedBy = "parentComment") + private List comments; + + @OneToMany(mappedBy = "comment") + private List commentLikes; + + @Column + private int viewCnt; + + @Builder + private Comment(User writer, String content, Article article) { + this.writer = writer; + this.content = content; + this.article = article; + this.deletedStatus = DeletedStatus.NOT_DELETED; + } + + public static Comment createComment(User writer, String content, Article article) { + return Comment.builder() + .writer(writer) + .content(content) + .article(article) + .build(); + } + public void updateParentComment(Comment parentComment) { + this.parentComment = parentComment; + } + + public void addComment(List comments) { + if(this.comments == null){ + this.comments = new ArrayList<>(); + } + this.comments.addAll(comments); + } + + public void addCommentLike(List commentLikes) { + if(this.commentLikes == null){ + this.commentLikes = new ArrayList<>(); + } + this.commentLikes.addAll(commentLikes); + } + + public void deleteComment() { + this.deletedStatus = DeletedStatus.DELETED; + } + + public void updateViewCount() { + this.viewCnt++; + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/domain/repository/CommentRepository.java b/src/main/java/com/leets/xcellentbe/domain/comment/domain/repository/CommentRepository.java index f248ab1..a87cda3 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/domain/repository/CommentRepository.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/domain/repository/CommentRepository.java @@ -1,10 +1,26 @@ package com.leets.xcellentbe.domain.comment.domain.repository; +import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import com.leets.xcellentbe.domain.article.domain.Article; import com.leets.xcellentbe.domain.comment.domain.Comment; public interface CommentRepository extends JpaRepository { + + @Query("SELECT c FROM Comment c WHERE c.parentComment = :parentComment AND c.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + List findAllByParentCommentAndNotDeleted(@Param("parentComment") Comment parentComment); + + @Query("SELECT c FROM Comment c WHERE c.article = :article AND c.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + List findAllByArticleAndNotDeleted(@Param("article") Article article); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.article = :article AND c.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + long countCommentsByArticle(@Param("article") Article article); + + @Query("SELECT COUNT(c) FROM Comment c WHERE c.parentComment = :comment AND c.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + long countRepliesByComment(@Param("comment") Comment comment); } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentCreateRequestDto.java b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentCreateRequestDto.java new file mode 100644 index 0000000..53b04da --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentCreateRequestDto.java @@ -0,0 +1,10 @@ +package com.leets.xcellentbe.domain.comment.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentCreateRequestDto { + private String content; +} diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentRequestDto.java b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentRequestDto.java deleted file mode 100644 index 71aa982..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentRequestDto.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.leets.xcellentbe.domain.comment.dto; - -public class CommentRequestDto { -} diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentResponseDto.java b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentResponseDto.java index 75b3370..a19e418 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentResponseDto.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentResponseDto.java @@ -1,4 +1,79 @@ package com.leets.xcellentbe.domain.comment.dto; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.shared.DeletedStatus; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor public class CommentResponseDto { + private UUID commentId; + private Long writerId; + private String content; + private DeletedStatus deletedStatus; + private UUID rePostId; + private int viewCnt; + private long likeCnt; + private long commentCnt; + private boolean owner; + private List comments; + + @Builder + private CommentResponseDto(UUID commentId, Long writerId, String content, DeletedStatus deletedStatus, + UUID rePostId, int viewCnt, long likeCnt, long commentCnt, boolean owner, List comments) { + this.commentId = commentId; + this.writerId = writerId; + this.content = content; + this.deletedStatus = deletedStatus; + this.rePostId = rePostId; + this.viewCnt = viewCnt; + this.likeCnt = likeCnt; + this.commentCnt = commentCnt; + this.owner = owner; + this.comments = comments; + } + + public static CommentResponseDto from(Comment comment, boolean isOwner, CommentStatsDto stats, Map replyStatsMap, int depth) { + if (depth <= 0 || comment == null) { // 깊이 제한 또는 null일 때 호출 중단 + return CommentResponseDto.builder() + .commentId(comment.getCommentId()) + .writerId(comment.getWriter().getUserId()) + .content(comment.getContent()) + .deletedStatus(comment.getDeletedStatus()) + .viewCnt(comment.getViewCnt()) + .likeCnt(stats.getLikeCnt()) + .commentCnt(stats.getCommentCnt()) + .owner(isOwner) + .comments(Collections.emptyList()) // 더 이상 하위 댓글 포함 안 함 + .build(); + } + + return CommentResponseDto.builder() + .commentId(comment.getCommentId()) + .writerId(comment.getWriter().getUserId()) + .content(comment.getContent()) + .deletedStatus(comment.getDeletedStatus()) + .viewCnt(comment.getViewCnt()) + .likeCnt(stats.getLikeCnt()) + .commentCnt(stats.getCommentCnt()) + .owner(isOwner) + .comments(comment.getComments().stream() + .filter(reply -> reply != null && reply.getDeletedStatus() == DeletedStatus.NOT_DELETED) // 삭제된 댓글 제외 + .map(reply -> { + CommentStatsDto replyStats = replyStatsMap.getOrDefault(reply.getCommentId(), CommentStatsDto.from(0, 0)); + boolean isReplyOwner = reply.getWriter().getUserId().equals(comment.getWriter().getUserId()); + return CommentResponseDto.from(reply, isReplyOwner, replyStats, replyStatsMap, depth - 1); // 깊이 줄임 + }) + .collect(Collectors.toList())) + .build(); + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentStatsDto.java b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentStatsDto.java new file mode 100644 index 0000000..f29be45 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/comment/dto/CommentStatsDto.java @@ -0,0 +1,25 @@ +package com.leets.xcellentbe.domain.comment.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentStatsDto { + private long likeCnt; + private long commentCnt; + + @Builder + private CommentStatsDto(long likeCnt, long commentCnt) { + this.likeCnt = likeCnt; + this.commentCnt = commentCnt; + } + + public static CommentStatsDto from(long likeCnt, long commentCnt) { + return CommentStatsDto.builder() + .likeCnt(likeCnt) + .commentCnt(commentCnt) + .build(); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/exception/CommentNotFoundException.java b/src/main/java/com/leets/xcellentbe/domain/comment/exception/CommentNotFoundException.java index 8314d58..ab7eb89 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/exception/CommentNotFoundException.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/exception/CommentNotFoundException.java @@ -1,4 +1,10 @@ package com.leets.xcellentbe.domain.comment.exception; -public class CommentNotFoundException { +import com.leets.xcellentbe.global.error.ErrorCode; +import com.leets.xcellentbe.global.error.exception.CommonException; + +public class CommentNotFoundException extends CommonException { + public CommentNotFoundException() { + super(ErrorCode.COMMENT_NOT_FOUND); + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/comment/service/CommentService.java b/src/main/java/com/leets/xcellentbe/domain/comment/service/CommentService.java index 4766ac5..790a2dc 100644 --- a/src/main/java/com/leets/xcellentbe/domain/comment/service/CommentService.java +++ b/src/main/java/com/leets/xcellentbe/domain/comment/service/CommentService.java @@ -1,4 +1,149 @@ package com.leets.xcellentbe.domain.comment.service; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.leets.xcellentbe.domain.article.domain.Article; +import com.leets.xcellentbe.domain.article.domain.repository.ArticleRepository; +import com.leets.xcellentbe.domain.article.exception.ArticleNotFoundException; +import com.leets.xcellentbe.domain.comment.dto.CommentStatsDto; +import com.leets.xcellentbe.domain.commentLike.domain.repository.CommentLikeRepository; +import com.leets.xcellentbe.global.error.exception.custom.DeleteForbiddenException; +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.comment.domain.repository.CommentRepository; +import com.leets.xcellentbe.domain.comment.dto.CommentCreateRequestDto; +import com.leets.xcellentbe.domain.comment.dto.CommentResponseDto; +import com.leets.xcellentbe.domain.comment.exception.CommentNotFoundException; +import com.leets.xcellentbe.domain.user.domain.User; +import com.leets.xcellentbe.domain.user.domain.repository.UserRepository; +import com.leets.xcellentbe.domain.user.exception.UserNotFoundException; +import com.leets.xcellentbe.global.auth.jwt.JwtService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor public class CommentService { + + private final ArticleRepository articleRepository; + private final UserRepository userRepository; + private final CommentLikeRepository commentLikeRepository; + private final JwtService jwtService; + private final CommentRepository commentRepository; + + //댓글 작성 + public void createComment(HttpServletRequest request, + CommentCreateRequestDto commentCreateRequestDto, UUID articleId, UUID parentCommentId) { + + List commentList = new ArrayList<>(); + User writer = getUser(request); + Article targetArticle = articleRepository.findById(articleId) + .orElseThrow(ArticleNotFoundException::new); + String content = commentCreateRequestDto.getContent(); + + if (parentCommentId != null) { + // 부모 댓글이 있으면 대댓글로 생성 + Comment parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(CommentNotFoundException::new); + Comment newComment = Comment.createComment(writer, content, targetArticle); + newComment.updateParentComment(parentComment); + commentList.add(newComment); + parentComment.addComment(commentList); + commentRepository.save(newComment); + } + else { + // 댓글 생성 + Comment newComment = Comment.createComment(writer, content, targetArticle); + commentList.add(newComment); + targetArticle.addComments(commentList); + commentRepository.save(newComment); + } + } + + //댓글 삭제 (상태 변경) + public void deleteComment(HttpServletRequest request, UUID commentId) { + User user = getUser(request); + + Comment targetComment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + if(!(targetComment.getWriter().getUserId().equals(user.getUserId()))){ + throw new DeleteForbiddenException(); + } + + if (targetComment.getParentComment() == null) { + targetComment.deleteComment(); + commentRepository.save(targetComment); + } + } + + //대댓글 삭제 + public void deleteReply(HttpServletRequest request, UUID commentId, UUID replyId) { + User user = getUser(request); + + // 부모 댓글 조회 (commentId) + Comment parentComment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + // 대댓글(replyId) 조회 + Comment replyComment = commentRepository.findById(replyId) + .orElseThrow(CommentNotFoundException::new); + + if (!replyComment.getWriter().getUserId().equals(user.getUserId())) { + throw new DeleteForbiddenException(); + } + + // 대댓글이 해당 부모 댓글의 하위에 있는지 확인 + if (!replyComment.getParentComment().equals(parentComment)) { + throw new DeleteForbiddenException(); + } + replyComment.deleteComment(); + commentRepository.save(replyComment); + } + + //댓글 단건 조회 + public CommentResponseDto getComment(HttpServletRequest request, UUID commentId) { + + User user = getUser(request); + + Comment targetComment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + targetComment.updateViewCount(); + boolean isOwner = targetComment.getWriter().getUserId().equals(user.getUserId()); + + long likeCount = commentLikeRepository.countLikesByComment(targetComment); + long replyCount = commentRepository.countRepliesByComment(targetComment); + CommentStatsDto stats = CommentStatsDto.from(likeCount, replyCount); + + List replies = commentRepository.findAllByParentCommentAndNotDeleted(targetComment); + Map replyStatsMap = replies.stream() + .collect(Collectors.toMap( + Comment::getCommentId, + reply -> { + long replyLikeCount = commentLikeRepository.countLikesByComment(reply); + long replyReplyCount = commentRepository.countRepliesByComment(reply); + return CommentStatsDto.from(replyLikeCount, replyReplyCount); + } + )); + + return CommentResponseDto.from(targetComment, isOwner, stats, replyStatsMap, 2); + } + + private User getUser(HttpServletRequest request) { + User user = jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .flatMap(jwtService::extractEmail) + .flatMap(userRepository::findByEmail) + .orElseThrow(UserNotFoundException::new); + + return user; + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/controller/CommentLikeController.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/controller/CommentLikeController.java new file mode 100644 index 0000000..afbe2bb --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/controller/CommentLikeController.java @@ -0,0 +1,48 @@ +package com.leets.xcellentbe.domain.commentLike.controller; + +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.leets.xcellentbe.domain.commentLike.dto.CommentLikeResponseDto; +import com.leets.xcellentbe.domain.commentLike.service.CommentLikeService; +import com.leets.xcellentbe.global.response.GlobalResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/article/{articleId}/{commentId}") +@RequiredArgsConstructor +public class CommentLikeController { + + private final CommentLikeService commentLikeService; + + @PostMapping("/like") + @Operation(summary = "신규 댓글 좋아요 등록", description = "댓글에 좋아요 했습니다.") + public ResponseEntity> commentLike( + HttpServletRequest request, + @PathVariable UUID commentId){ + CommentLikeResponseDto responseDto = commentLikeService.likeComment(request, commentId); + + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(responseDto)); + } + + @PatchMapping("/unlike") + @Operation(summary = "댓글 좋아요 삭제", description = "댓글에 좋아요를 취소했습니다.") + public ResponseEntity> commentUnLike( + HttpServletRequest request, + @PathVariable UUID commentId){ + commentLikeService.unLikeComment(request, commentId); + + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success()); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/CommentLike.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/CommentLike.java new file mode 100644 index 0000000..13cc45c --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/CommentLike.java @@ -0,0 +1,67 @@ +package com.leets.xcellentbe.domain.commentLike.domain; + +import java.util.UUID; + +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.shared.BaseTimeEntity; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentLike extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID CommentLikeId; + + @NotNull + @Column(columnDefinition = "VARCHAR(30)") + @Enumerated(EnumType.STRING) + private DeletedStatus deletedStatus; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder + private CommentLike(Comment comment, User user) { + this.comment = comment; + this.user = user; + this.deletedStatus = DeletedStatus.NOT_DELETED; + } + + public static CommentLike create(Comment comment, User user) { + return CommentLike.builder() + .comment(comment) + .user(user) + .build(); + } + + public void deleteCommentLike() { + this.deletedStatus = DeletedStatus.DELETED; + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/repository/CommentLikeRepository.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/repository/CommentLikeRepository.java new file mode 100644 index 0000000..5037a5b --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/domain/repository/CommentLikeRepository.java @@ -0,0 +1,20 @@ +package com.leets.xcellentbe.domain.commentLike.domain.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.commentLike.domain.CommentLike; +import com.leets.xcellentbe.domain.shared.DeletedStatus; + +public interface CommentLikeRepository extends JpaRepository { + + Optional findByComment_CommentIdAndUser_UserIdAndDeletedStatus(UUID commentId, Long userId, DeletedStatus status); + + @Query("SELECT COUNT(l) FROM CommentLike l WHERE l.comment = :comment AND l.deletedStatus = com.leets.xcellentbe.domain.shared.DeletedStatus.NOT_DELETED") + long countLikesByComment(@Param("comment") Comment comment); +} diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/dto/CommentLikeResponseDto.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/dto/CommentLikeResponseDto.java new file mode 100644 index 0000000..2a6514e --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/dto/CommentLikeResponseDto.java @@ -0,0 +1,31 @@ +package com.leets.xcellentbe.domain.commentLike.dto; + +import java.util.UUID; + +import com.leets.xcellentbe.domain.commentLike.domain.CommentLike; +import com.leets.xcellentbe.domain.shared.DeletedStatus; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentLikeResponseDto { + private UUID commentId; + private Long userId; + private DeletedStatus status; + + @Builder + private CommentLikeResponseDto(UUID commentId, DeletedStatus status) { + this.commentId = commentId; + this.status = status; + } + + public static CommentLikeResponseDto from(CommentLike commentLike) { + return CommentLikeResponseDto.builder() + .commentId(commentLike.getComment().getCommentId()) + .status(commentLike.getDeletedStatus()) + .build(); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/exception/CommentLikeNotFoundException.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/exception/CommentLikeNotFoundException.java new file mode 100644 index 0000000..6b176a3 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/exception/CommentLikeNotFoundException.java @@ -0,0 +1,10 @@ +package com.leets.xcellentbe.domain.commentLike.exception; + +import com.leets.xcellentbe.global.error.ErrorCode; +import com.leets.xcellentbe.global.error.exception.CommonException; + +public class CommentLikeNotFoundException extends CommonException { + public CommentLikeNotFoundException() { + super(ErrorCode.COMMENT_LIKE_NOT_FOUND); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/commentLike/service/CommentLikeService.java b/src/main/java/com/leets/xcellentbe/domain/commentLike/service/CommentLikeService.java new file mode 100644 index 0000000..bddf49f --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/commentLike/service/CommentLikeService.java @@ -0,0 +1,73 @@ +package com.leets.xcellentbe.domain.commentLike.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.leets.xcellentbe.domain.comment.domain.Comment; +import com.leets.xcellentbe.domain.comment.domain.repository.CommentRepository; +import com.leets.xcellentbe.domain.comment.exception.CommentNotFoundException; +import com.leets.xcellentbe.domain.commentLike.domain.CommentLike; +import com.leets.xcellentbe.domain.commentLike.domain.repository.CommentLikeRepository; +import com.leets.xcellentbe.domain.commentLike.dto.CommentLikeResponseDto; +import com.leets.xcellentbe.domain.commentLike.exception.CommentLikeNotFoundException; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; +import com.leets.xcellentbe.domain.user.domain.repository.UserRepository; +import com.leets.xcellentbe.domain.user.exception.UserNotFoundException; +import com.leets.xcellentbe.global.auth.jwt.JwtService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class CommentLikeService { + private final CommentLikeRepository commentLikeRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final JwtService jwtService; + + public CommentLikeResponseDto likeComment(HttpServletRequest request, UUID commentId) { + User user = getUser(request); + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + List commentLikeList = new ArrayList<>(); + + Optional existingLike = commentLikeRepository.findByComment_CommentIdAndUser_UserIdAndDeletedStatus( + commentId, user.getUserId(), DeletedStatus.NOT_DELETED); + if (existingLike.isPresent()) { + return CommentLikeResponseDto.from(existingLike.get()); + } + + CommentLike commentLike = CommentLike.create(comment, user); + commentLikeList.add(commentLike); + comment.addCommentLike(commentLikeList); + return CommentLikeResponseDto.from(commentLikeRepository.save(commentLike)); + } + + public void unLikeComment(HttpServletRequest request, UUID commentId) { + User user = getUser(request); + CommentLike commentLike = commentLikeRepository.findByComment_CommentIdAndUser_UserIdAndDeletedStatus( + commentId, user.getUserId(), DeletedStatus.NOT_DELETED) + .orElseThrow(CommentLikeNotFoundException::new); + commentLike.deleteCommentLike(); + commentLikeRepository.save(commentLike); + } + + //JWT 토큰 기반 사용자 정보 반환 메소드 + private User getUser(HttpServletRequest request) { + User user = jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .flatMap(jwtService::extractEmail) + .flatMap(userRepository::findByEmail) + .orElseThrow(UserNotFoundException::new); + + return user; + } +} diff --git a/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java b/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java index 64cc7b4..a2ce424 100644 --- a/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java +++ b/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java @@ -13,15 +13,18 @@ public enum ErrorCode { LOGIN_FAIL(401, "LOGIN_FAIL", "로그인에 실패하였습니다."), CHAT_ROOM_FORBIDDEN(403, "CHAT_ROOM_FORBIDDEN", "권한이 없는 채팅방입니다."), DELETE_FORBIDDEN(403, "DELETE_FORBIDDEN", "삭제 권한이 없습니다."), - USER_NOT_FOUND(404, "USER_NOT_FOUND", "유저를 찾을 수 없습니다."), EXPIRED_TOKEN(403, "EXPIRED_TOKEN", "만료된 토큰입니다."), - FOLLOW_OPERATION_ERROR(409, "FOLLOW_OPERATION_ERROR", "적절하지 않은 팔로우 요청입니다."), - USER_ALREADY_EXISTS(412, "ALREADY_EXISTS_EXCEPTION", "이미 존재하는 사용자입니다."), + USER_NOT_FOUND(404, "USER_NOT_FOUND", "유저를 찾을 수 없습니다."), + COMMENT_NOT_FOUND(404, "COMMENT_NOT_FOUND", "댓글을 찾을 수 없습니다."), ARTICLE_NOT_FOUND(404, "ARTICLE_NOT_FOUND", "게시물을 찾을 수 없습니다."), + ARTICLE_LIKE_NOT_FOUND(404, "ARTICLE_LIKE_NOT_FOUND", "좋아요를 찾을 수 없습니다."), ARTICLE_MEDIA_NOT_FOUND(404, "ARTICLE_MEDIA_NOT_FOUND", "게시물 이미지를 찾을 수 없습니다."), + COMMENT_LIKE_NOT_FOUND(404, "COMMENT_LIKE_NOT_FOUND", "댓글 좋아요를 찾을 수 없습니다."), CHAT_ROOM_NOT_FOUND(404, "CHAT_ROOM_NOT_FOUND", "채팅방을 찾을 수 없습니다."), DM_NOT_FOUND(404, "DM_NOT_FOUND", "메시지를 찾을 수 없습니다."), + FOLLOW_OPERATION_ERROR(409, "FOLLOW_OPERATION_ERROR", "적절하지 않은 팔로우 요청입니다."), REJECT_DUPLICATION(409, "REJECT_DUPLICATION", "중복된 값입니다."), + USER_ALREADY_EXISTS(412, "ALREADY_EXISTS_EXCEPTION", "이미 존재하는 사용자입니다."), AUTH_CODE_ALREADY_SENT(429, "AUTH_CODE_ALREADY_SENT", "이미 인증번호를 전송했습니다."), INTERNAL_SERVER_ERROR(500, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."), EMAIL_CANNOT_BE_SENT(500, "EMAIL_CANNOT_BE_SENT", "이메일 전송에 실패했습니다."); diff --git a/src/main/java/com/leets/xcellentbe/domain/article/exception/DeleteForbiddenException.java b/src/main/java/com/leets/xcellentbe/global/error/exception/custom/DeleteForbiddenException.java similarity index 81% rename from src/main/java/com/leets/xcellentbe/domain/article/exception/DeleteForbiddenException.java rename to src/main/java/com/leets/xcellentbe/global/error/exception/custom/DeleteForbiddenException.java index 51e3b3d..34eba45 100644 --- a/src/main/java/com/leets/xcellentbe/domain/article/exception/DeleteForbiddenException.java +++ b/src/main/java/com/leets/xcellentbe/global/error/exception/custom/DeleteForbiddenException.java @@ -1,4 +1,4 @@ -package com.leets.xcellentbe.domain.article.exception; +package com.leets.xcellentbe.global.error.exception.custom; import com.leets.xcellentbe.global.error.ErrorCode; import com.leets.xcellentbe.global.error.exception.CommonException;