Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package leets.leenk.domain.feed.application.dto.request;

import jakarta.validation.constraints.NotBlank;

public record CommentWriteRequest(
@NotBlank
String comment
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package leets.leenk.domain.feed.application.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.swagger.v3.oas.annotations.media.Schema;
import leets.leenk.domain.user.application.dto.response.UserProfileResponse;
import lombok.Builder;

import java.time.LocalDateTime;

@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FeedCommentResponse(
@Schema(description = "댓글 id", example = "1")
long commentId,

@JsonUnwrapped
@Schema(implementation = UserProfileResponse.class)
UserProfileResponse user,

@Schema(description = "댓글", example = "오 좋은데??")
String comment,

@Schema(description = "댓글 작성 시간", example = "2025-06-30T00:00:00")
LocalDateTime createdAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public record FeedDetailResponse(
long linkedUserCount,

@Schema(description = "함께한 사용자 목록")
List<LinkedUserResponse> linkedUser
List<LinkedUserResponse> linkedUser,

@Schema(description = "피드에 작성된 댓글 목록")
List<FeedCommentResponse> comments
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package leets.leenk.domain.feed.application.exception;

import leets.leenk.global.common.exception.BaseException;

public class CommentDeleteNotAllowedException extends BaseException {
public CommentDeleteNotAllowedException() {
super(ErrorCode.COMMENT_DELETE_NOT_ALLOWED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package leets.leenk.domain.feed.application.exception;

import leets.leenk.global.common.exception.BaseException;

public class CommentNotFoundException extends BaseException {
public CommentNotFoundException() {
super(ErrorCode.COMMENT_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public enum ErrorCode implements ErrorCodeInterface {
FEED_NOT_FOUND(2200, HttpStatus.NOT_FOUND, "존재하지 않는 피드입니다."),
SELF_REACTION(2202, HttpStatus.FORBIDDEN, "자신의 피드에 공감할 수 없습니다."),
FEED_DELETE_NOT_ALLOWED(2203, HttpStatus.FORBIDDEN, "피드 삭제는 작성자만 가능합니다."),
FEED_UPDATE_NOT_ALLOWED(2204, HttpStatus.FORBIDDEN, "피드 수정은 작성자만 가능합니다.");
FEED_UPDATE_NOT_ALLOWED(2204, HttpStatus.FORBIDDEN, "피드 수정은 작성자만 가능합니다."),
Copy link
Collaborator

Choose a reason for hiding this comment

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

노션 명세 추가도 부탁드립니당

Copy link
Member Author

Choose a reason for hiding this comment

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

추가해두었습니다!!

COMMENT_NOT_FOUND(2205, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."),
COMMENT_DELETE_NOT_ALLOWED(2206, HttpStatus.FORBIDDEN, "댓글 삭제는 댓글 작성자만 가능합니다.");

private final int code;
private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package leets.leenk.domain.feed.application.mapper;

import leets.leenk.domain.feed.application.dto.request.CommentWriteRequest;
import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.entity.Feed;
import leets.leenk.domain.user.domain.entity.User;
import org.springframework.stereotype.Component;

@Component
public class CommentMapper {
public Comment toComment(User user, Feed feed, CommentWriteRequest request) {
return Comment.builder()
.user(user)
.feed(feed)
.comment(request.comment())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package leets.leenk.domain.feed.application.mapper;

import leets.leenk.domain.feed.application.dto.response.*;
import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.entity.Feed;
import leets.leenk.domain.feed.domain.entity.LinkedUser;
import leets.leenk.domain.media.application.dto.response.FeedMediaResponse;
Expand Down Expand Up @@ -76,7 +77,7 @@ public Feed toFeed(User user, String description) {
.build();
}

public FeedDetailResponse toFeedDetailResponse(Feed feed, List<Media> medias, List<LinkedUser> linkedUsers) {
public FeedDetailResponse toFeedDetailResponse(Feed feed, List<Media> medias, List<LinkedUser> linkedUsers, List<Comment> comments) {
return FeedDetailResponse.builder()
.feedId(feed.getId())
.author(toFeedAuthorResponse(feed))
Expand All @@ -86,6 +87,7 @@ public FeedDetailResponse toFeedDetailResponse(Feed feed, List<Media> medias, Li
.media(toFeedMediaResponses(medias))
.linkedUserCount(linkedUsers.size())
.linkedUser(toLinkedUserResponses(linkedUsers, feed))
.comments(toGetCommentsResponses(comments))
.build();
}

Expand All @@ -110,34 +112,49 @@ private List<LinkedUserResponse> toLinkedUserResponses(List<LinkedUser> linkedUs
.toList();
}

private List<FeedCommentResponse> toGetCommentsResponses(List<Comment> comments) {
return comments.stream()
.map(comment -> FeedCommentResponse.builder()
.commentId(comment.getCommentId())
.user(userProfileMapper.toProfile(comment.getUser()))
.comment(comment.getComment())
.createdAt(comment.getCreateDate())
.build())
.toList();
}

public FeedNavigationResponse toFeedNavigationResponse(
Feed currentFeed,
List<Feed> prevFeeds,
List<Feed> nextFeeds,
Map<Long, List<Media>> mediaMap,
Map<Long, List<LinkedUser>> linkedUserMap,
Map<Long, List<Comment>> commentsMap,
boolean hasMorePrev,
boolean hasMoreNext
) {
FeedDetailResponse current = toFeedDetailResponse(
currentFeed,
mediaMap.getOrDefault(currentFeed.getId(), List.of()),
linkedUserMap.getOrDefault(currentFeed.getId(), List.of())
linkedUserMap.getOrDefault(currentFeed.getId(), List.of()),
commentsMap.getOrDefault(currentFeed.getId(), List.of())
);

List<FeedDetailResponse> prevFeedResponses = prevFeeds.stream()
.map(feed -> toFeedDetailResponse(
feed,
mediaMap.getOrDefault(feed.getId(), List.of()),
linkedUserMap.getOrDefault(feed.getId(), List.of())
linkedUserMap.getOrDefault(feed.getId(), List.of()),
commentsMap.getOrDefault(feed.getId(), List.of())
))
.toList();

List<FeedDetailResponse> nextFeedResponses = nextFeeds.stream()
.map(feed -> toFeedDetailResponse(
feed,
mediaMap.getOrDefault(feed.getId(), List.of()),
linkedUserMap.getOrDefault(feed.getId(), List.of())
linkedUserMap.getOrDefault(feed.getId(), List.of()),
commentsMap.getOrDefault(feed.getId(), List.of())
))
.toList();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package leets.leenk.domain.feed.application.usecase;

import leets.leenk.domain.feed.application.dto.request.FeedReportRequest;
import leets.leenk.domain.feed.application.dto.request.FeedUpdateRequest;
import leets.leenk.domain.feed.application.dto.request.FeedUploadRequest;
import leets.leenk.domain.feed.application.dto.request.ReactionRequest;
import leets.leenk.domain.feed.application.dto.request.*;
import leets.leenk.domain.feed.application.dto.response.*;
import leets.leenk.domain.feed.application.exception.CommentDeleteNotAllowedException;
import leets.leenk.domain.feed.application.exception.FeedDeleteNotAllowedException;
import leets.leenk.domain.feed.application.exception.FeedUpdateNotAllowedException;
import leets.leenk.domain.feed.application.exception.SelfReactionNotAllowedException;
import leets.leenk.domain.feed.application.mapper.CommentMapper;
import leets.leenk.domain.feed.application.mapper.FeedMapper;
import leets.leenk.domain.feed.application.mapper.FeedUserMapper;
import leets.leenk.domain.feed.application.mapper.ReactionMapper;
import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.entity.Feed;
import leets.leenk.domain.feed.domain.entity.LinkedUser;
import leets.leenk.domain.feed.domain.entity.Reaction;
Expand Down Expand Up @@ -66,12 +66,17 @@ public class FeedUsecase {
private final ReactionGetService reactionGetService;
private final ReactionSaveService reactionSaveService;

private final CommentSaveService commentSaveService;
private final CommentGetService commentGetService;
private final CommentDeleteService commentDeleteService;

private final FeedNotificationUsecase feedNotificationUsecase;

private final FeedMapper feedMapper;
private final MediaMapper mediaMapper;
private final FeedUserMapper feedUserMapper;
private final ReactionMapper reactionMapper;
private final CommentMapper commentMapper;

@Transactional(readOnly = true)
public FeedListResponse getFeeds(long userId, int pageNumber, int pageSize) {
Expand All @@ -95,8 +100,9 @@ public FeedDetailResponse getFeedDetail(Long feedId) {
Feed feed = feedGetService.findById(feedId);
List<Media> medias = mediaGetService.findAllByFeed(feed);
List<LinkedUser> linkedUsers = linkedUserGetService.findAll(feed);
List<Comment> comments = commentGetService.findAllByFeed(feed);

return feedMapper.toFeedDetailResponse(feed, medias, linkedUsers);
return feedMapper.toFeedDetailResponse(feed, medias, linkedUsers, comments);
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -146,12 +152,19 @@ public FeedNavigationResponse getFeedNavigation(
linkedUserMap.put(feed.getId(), linkedUsers);
}

Map<Long, List<Comment>> commentsMap = new HashMap<>();
for (Feed feed : allFeeds) {
List<Comment> comments = commentGetService.findAllByFeed(feed);
commentsMap.put(feed.getId(), comments);
}
Comment on lines +155 to +159
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the CommentGetService file
fd CommentGetService --type f

Repository: Leets-Makers/Leenk-BE

Length of output: 140


🏁 Script executed:

# Search for batch methods in CommentGetService
rg -A 10 'findAllByFeeds|findByFeedIn|findAllByFeedIn' --type=java

Repository: Leets-Makers/Leenk-BE

Length of output: 7861


🏁 Script executed:

# Also search for the current method being used (findAllByFeed)
rg -B 5 -A 10 'findAllByFeed' --type=java -g '*CommentGetService*'

Repository: Leets-Makers/Leenk-BE

Length of output: 1075


🏁 Script executed:

# Check the FeedUsecase to understand the full context around lines 155-159
rg -B 10 -A 15 'commentsMap.*HashMap' src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java

Repository: Leets-Makers/Leenk-BE

Length of output: 1067


Implement batch query method in CommentGetService to avoid N+1 query issue

The concern is valid. CommentGetService only provides findAllByFeed(Feed feed) for single-feed queries, while the code loops through all feeds to fetch comments individually (lines 155-159). This creates an N+1 query problem.

The codebase already demonstrates the correct pattern: MediaGetService implements findAllByFeeds(List<Feed> feeds) and is used elsewhere in FeedUsecase. Apply the same pattern to CommentGetService by adding a batch method (e.g., findAllByFeeds(List<Feed> feeds)) that queries all comments in a single database call, then update lines 155-159 to use this new method.

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
around lines 155-159, the current loop calls
commentGetService.findAllByFeed(feed) per feed causing an N+1 query; add a batch
method to CommentGetService (e.g., findAllByFeeds(List<Feed> feeds) or
findAllByFeedIds(List<Long> feedIds)) that performs a single DB query for all
comments for the given feeds, implement it in the service/repository to query by
feed IDs and return either a Map<Long, List<Comment>> or a flat List<Comment>
which you then group by feedId, and replace the loop with a single call to this
new batch method and use the returned grouped results to populate commentsMap.


return feedMapper.toFeedNavigationResponse(
currentFeed,
prevFeeds,
nextFeeds,
mediaMap,
linkedUserMap,
commentsMap,
hasMorePrev,
hasMoreNext
);
Expand Down Expand Up @@ -219,6 +232,16 @@ public void reactToFeed(long userId, long feedId, ReactionRequest request) {
notifyIfReachedReactionMilestone(previousReactionCount, updatedReactionCount, feed);
}

@Transactional
public void writeComment(long userId, long feedId, CommentWriteRequest request) {
User user = userGetService.findById(userId);
Feed feed = feedGetService.findById(feedId);

Comment comment = commentMapper.toComment(user, feed, request);

commentSaveService.saveComment(comment);
}
Comment on lines +235 to +243
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

댓글 작성 후 알림 기능 추가를 고려하세요.

현재 댓글 작성 기능은 구현되었지만, 피드 작성자에게 새 댓글 알림을 전송하는 로직이 누락되었습니다. reactToFeed 메서드(lines 214-233)에서 리액션 알림을 전송하는 것처럼, 댓글 작성 시에도 피드 작성자에게 알림을 보내는 것이 사용자 경험 측면에서 중요합니다.

다음과 같이 알림 로직을 추가할 수 있습니다:

     @Transactional
     public void writeComment(long userId, long feedId, CommentWriteRequest request) {
         User user = userGetService.findById(userId);
         Feed feed = feedGetService.findById(feedId);
 
         Comment comment = commentMapper.toComment(user, feed, request);
 
         commentSaveService.saveComment(comment);
+        
+        // 피드 작성자에게 댓글 알림 전송 (자기 자신은 제외)
+        if (!feed.getUser().equals(user)) {
+            feedNotificationUsecase.saveCommentNotification(feed, comment, user);
+        }
     }

FeedNotificationUsecasesaveCommentNotification 메서드 구현이 필요한 경우 도움을 드릴 수 있습니다. 구현을 원하시나요?

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java
around lines 235-243, the writeComment method currently saves a comment but does
not send a notification to the feed author; add a call after
commentSaveService.saveComment(comment) to invoke
FeedNotificationUsecase.saveCommentNotification(feed.getAuthor(), user, feed,
comment) (or equivalent) to create/send the notification, ensuring you avoid
notifying the author if they are the commenter and handle any checked
exceptions; if FeedNotificationUsecase lacks saveCommentNotification, implement
that method to persist and/or dispatch the notification with necessary DTOs.


private void validateReaction(Feed feed, User user) {
if (feed.getUser().equals(user)) {
throw new SelfReactionNotAllowedException();
Expand Down Expand Up @@ -325,6 +348,18 @@ public void deleteFeed(long userId, long feedId) {
feedDeleteService.delete(feed);
}

@Transactional
public void deleteComment(long userId, long commentId) {
User user = userGetService.findById(userId);
Comment comment = commentGetService.findCommentByIdNotDeleted(commentId);

if (!comment.getUser().equals(user)) {
throw new CommentDeleteNotAllowedException();
}

commentDeleteService.deleteComment(comment);
}

@Transactional(readOnly = true)
public void reportFeed(long userId, long feedId, FeedReportRequest request) {
User user = userGetService.findById(userId);
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package leets.leenk.domain.feed.domain.entity;

import jakarta.persistence.*;
import leets.leenk.domain.user.domain.entity.User;
import leets.leenk.global.common.entity.BaseEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;

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

Comment on lines +17 to +23
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find CommentGetService file
fd -t f "CommentGetService" --extension java

Repository: Leets-Makers/Leenk-BE

Length of output: 140


🏁 Script executed:

# Also search for findCommentById usage and implementation
rg "findCommentById" -t java -A 5

Repository: Leets-Makers/Leenk-BE

Length of output: 1388


🏁 Script executed:

# Check the Comment entity file to understand the full structure
cat -n src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java

Repository: Leets-Makers/Leenk-BE

Length of output: 1487


🏁 Script executed:

# Look for BaseEntity to understand soft delete handling pattern
fd -t f "BaseEntity" --extension java | head -5

Repository: Leets-Makers/Leenk-BE

Length of output: 127


🏁 Script executed:

cat -n src/main/java/leets/leenk/global/common/entity/BaseEntity.java

Repository: Leets-Makers/Leenk-BE

Length of output: 1040


🏁 Script executed:

# Find CommentRepository
fd -t f "CommentRepository" --extension java

Repository: Leets-Makers/Leenk-BE

Length of output: 143


🏁 Script executed:

# Check the repository interface
cat -n src/main/java/leets/leenk/domain/feed/domain/repository/CommentRepository.java

Repository: Leets-Makers/Leenk-BE

Length of output: 544


🏁 Script executed:

# Check all methods in CommentGetService
cat -n src/main/java/leets/leenk/domain/feed/domain/service/CommentGetService.java

Repository: Leets-Makers/Leenk-BE

Length of output: 1101


findCommentById()에서 soft delete된 댓글 반환 가능성

CommentGetService.findCommentById()deletedAt 필드를 확인하지 않아 soft delete된 댓글도 반환합니다. 같은 서비스의 findAllByFeed() 메서드는 findAllByFeedAndDeletedAtIsNullOrderByCreateDateDesc()를 사용하여 삭제된 댓글을 필터링하므로, findCommentById()도 동일하게 처리되어야 합니다.

삭제된 댓글 조회 시도 시 404 예외를 발생시켜야 한다면, repository에 findByIdAndDeletedAtIsNull() 메서드 추가를 권장합니다.

🤖 Prompt for AI Agents
In src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java around lines
17-23, the service method findCommentById() may return soft-deleted comments
because it doesn't filter by deletedAt; update the logic to exclude deleted
comments by adding a repository method like findByIdAndDeletedAtIsNull(Long id)
(or use existing findById(...) and explicitly check deletedAt == null) and
change findCommentById() to call that repository method and throw a 404 (e.g.,
EntityNotFoundException) when no non-deleted comment is found.

@Column(name = "comments")
private String comment;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "feed_id", nullable = false, updatable = false)
private Feed feed;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, updatable = false)
private User user;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

public void deleteComment() {
this.deletedAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package leets.leenk.domain.feed.domain.repository;

import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.entity.Feed;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface CommentRepository extends JpaRepository<Comment, Long> {
Optional<Comment> findByCommentIdAndDeletedAtIsNull(long id);

List<Comment> findAllByFeedAndDeletedAtIsNullOrderByCreateDateDesc(Feed feed);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package leets.leenk.domain.feed.domain.service;


import leets.leenk.domain.feed.domain.entity.Comment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CommentDeleteService {

public void deleteComment(Comment comment) {
comment.deleteComment();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package leets.leenk.domain.feed.domain.service;

import leets.leenk.domain.feed.application.exception.CommentNotFoundException;
import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.entity.Feed;
import leets.leenk.domain.feed.domain.repository.CommentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class CommentGetService {
private final CommentRepository commentRepository;

public Comment findCommentByIdNotDeleted(long commentId) {
return commentRepository.findByCommentIdAndDeletedAtIsNull(commentId)
.orElseThrow(CommentNotFoundException::new);
}

public List<Comment> findAllByFeed(Feed feed) {
return commentRepository.findAllByFeedAndDeletedAtIsNullOrderByCreateDateDesc(feed);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package leets.leenk.domain.feed.domain.service;

import leets.leenk.domain.feed.domain.entity.Comment;
import leets.leenk.domain.feed.domain.repository.CommentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CommentSaveService {
private final CommentRepository commentRepository;

public void saveComment(Comment comment) {
commentRepository.save(comment);
}
}
Loading