diff --git a/src/main/java/leets/leenk/domain/feed/application/dto/request/CommentWriteRequest.java b/src/main/java/leets/leenk/domain/feed/application/dto/request/CommentWriteRequest.java new file mode 100644 index 00000000..d2ad76a4 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/dto/request/CommentWriteRequest.java @@ -0,0 +1,9 @@ +package leets.leenk.domain.feed.application.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CommentWriteRequest( + @NotBlank + String comment +) { +} diff --git a/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedCommentResponse.java b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedCommentResponse.java new file mode 100644 index 00000000..6f6e104c --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedCommentResponse.java @@ -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 +) { +} diff --git a/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedDetailResponse.java b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedDetailResponse.java index 9ec76c12..7475d351 100644 --- a/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedDetailResponse.java +++ b/src/main/java/leets/leenk/domain/feed/application/dto/response/FeedDetailResponse.java @@ -33,6 +33,9 @@ public record FeedDetailResponse( long linkedUserCount, @Schema(description = "함께한 사용자 목록") - List linkedUser + List linkedUser, + + @Schema(description = "피드에 작성된 댓글 목록") + List comments ) { } diff --git a/src/main/java/leets/leenk/domain/feed/application/exception/CommentDeleteNotAllowedException.java b/src/main/java/leets/leenk/domain/feed/application/exception/CommentDeleteNotAllowedException.java new file mode 100644 index 00000000..f2ead794 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/exception/CommentDeleteNotAllowedException.java @@ -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); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/application/exception/CommentNotFoundException.java b/src/main/java/leets/leenk/domain/feed/application/exception/CommentNotFoundException.java new file mode 100644 index 00000000..72f309c2 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/exception/CommentNotFoundException.java @@ -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); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/application/exception/ErrorCode.java b/src/main/java/leets/leenk/domain/feed/application/exception/ErrorCode.java index 1402a838..f0bd809f 100644 --- a/src/main/java/leets/leenk/domain/feed/application/exception/ErrorCode.java +++ b/src/main/java/leets/leenk/domain/feed/application/exception/ErrorCode.java @@ -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, "피드 수정은 작성자만 가능합니다."), + COMMENT_NOT_FOUND(2205, HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + COMMENT_DELETE_NOT_ALLOWED(2206, HttpStatus.FORBIDDEN, "댓글 삭제는 댓글 작성자만 가능합니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/leets/leenk/domain/feed/application/mapper/CommentMapper.java b/src/main/java/leets/leenk/domain/feed/application/mapper/CommentMapper.java new file mode 100644 index 00000000..fc30f92a --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/application/mapper/CommentMapper.java @@ -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(); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java b/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java index 758ff4ca..7f420822 100644 --- a/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java +++ b/src/main/java/leets/leenk/domain/feed/application/mapper/FeedMapper.java @@ -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; @@ -76,7 +77,7 @@ public Feed toFeed(User user, String description) { .build(); } - public FeedDetailResponse toFeedDetailResponse(Feed feed, List medias, List linkedUsers) { + public FeedDetailResponse toFeedDetailResponse(Feed feed, List medias, List linkedUsers, List comments) { return FeedDetailResponse.builder() .feedId(feed.getId()) .author(toFeedAuthorResponse(feed)) @@ -86,6 +87,7 @@ public FeedDetailResponse toFeedDetailResponse(Feed feed, List medias, Li .media(toFeedMediaResponses(medias)) .linkedUserCount(linkedUsers.size()) .linkedUser(toLinkedUserResponses(linkedUsers, feed)) + .comments(toGetCommentsResponses(comments)) .build(); } @@ -110,26 +112,40 @@ private List toLinkedUserResponses(List linkedUs .toList(); } + private List toGetCommentsResponses(List 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 prevFeeds, List nextFeeds, Map> mediaMap, Map> linkedUserMap, + Map> 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 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(); @@ -137,7 +153,8 @@ public FeedNavigationResponse toFeedNavigationResponse( .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(); diff --git a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java index 764db225..56ffb232 100644 --- a/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java +++ b/src/main/java/leets/leenk/domain/feed/application/usecase/FeedUsecase.java @@ -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; @@ -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) { @@ -95,8 +100,9 @@ public FeedDetailResponse getFeedDetail(Long feedId) { Feed feed = feedGetService.findById(feedId); List medias = mediaGetService.findAllByFeed(feed); List linkedUsers = linkedUserGetService.findAll(feed); + List comments = commentGetService.findAllByFeed(feed); - return feedMapper.toFeedDetailResponse(feed, medias, linkedUsers); + return feedMapper.toFeedDetailResponse(feed, medias, linkedUsers, comments); } @Transactional(readOnly = true) @@ -146,12 +152,19 @@ public FeedNavigationResponse getFeedNavigation( linkedUserMap.put(feed.getId(), linkedUsers); } + Map> commentsMap = new HashMap<>(); + for (Feed feed : allFeeds) { + List comments = commentGetService.findAllByFeed(feed); + commentsMap.put(feed.getId(), comments); + } + return feedMapper.toFeedNavigationResponse( currentFeed, prevFeeds, nextFeeds, mediaMap, linkedUserMap, + commentsMap, hasMorePrev, hasMoreNext ); @@ -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); + } + private void validateReaction(Feed feed, User user) { if (feed.getUser().equals(user)) { throw new SelfReactionNotAllowedException(); @@ -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); diff --git a/src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java b/src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java new file mode 100644 index 00000000..8ce1d49b --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/entity/Comment.java @@ -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; + + @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(); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/domain/repository/CommentRepository.java b/src/main/java/leets/leenk/domain/feed/domain/repository/CommentRepository.java new file mode 100644 index 00000000..97fa060c --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/repository/CommentRepository.java @@ -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 { + Optional findByCommentIdAndDeletedAtIsNull(long id); + + List findAllByFeedAndDeletedAtIsNullOrderByCreateDateDesc(Feed feed); +} diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/CommentDeleteService.java b/src/main/java/leets/leenk/domain/feed/domain/service/CommentDeleteService.java new file mode 100644 index 00000000..eabc706a --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/service/CommentDeleteService.java @@ -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(); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/CommentGetService.java b/src/main/java/leets/leenk/domain/feed/domain/service/CommentGetService.java new file mode 100644 index 00000000..687f4d33 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/service/CommentGetService.java @@ -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 findAllByFeed(Feed feed) { + return commentRepository.findAllByFeedAndDeletedAtIsNullOrderByCreateDateDesc(feed); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/domain/service/CommentSaveService.java b/src/main/java/leets/leenk/domain/feed/domain/service/CommentSaveService.java new file mode 100644 index 00000000..aceed518 --- /dev/null +++ b/src/main/java/leets/leenk/domain/feed/domain/service/CommentSaveService.java @@ -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); + } +} diff --git a/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java b/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java index 94515a30..097e476b 100644 --- a/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java +++ b/src/main/java/leets/leenk/domain/feed/presentation/FeedController.java @@ -5,10 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; -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.usecase.FeedUsecase; import leets.leenk.global.auth.application.annotation.CurrentUserId; @@ -86,6 +83,16 @@ public CommonResponse reactToFeed(@Parameter(hidden = true) @CurrentUserId return CommonResponse.success(ResponseCode.CREATE_REACTION); } + @PostMapping("/{feedId}/comments") + @Operation(summary = "댓글 작성 API") + public CommonResponse writeComment(@Parameter(hidden = true) @CurrentUserId Long userId, + @PathVariable @Positive long feedId, + @RequestBody @Valid CommentWriteRequest request) { + feedUsecase.writeComment(userId, feedId, request); + + return CommonResponse.success(ResponseCode.WRITE_COMMENT); + } + @GetMapping("/{feedId}/reactions") @Operation(summary = "피드 공감 유저 목록 조회 API") public CommonResponse> getLikedUsers(@PathVariable @Positive long feedId) { @@ -187,4 +194,13 @@ public CommonResponse deleteFeed(@Parameter(hidden = true) @CurrentUserId return CommonResponse.success(ResponseCode.DELETE_FEED); } + + @DeleteMapping("/comments/{commentId}") + @Operation(summary = "댓글 삭제 API") + public CommonResponse deleteComment(@Parameter(hidden = true) @CurrentUserId Long userId, + @PathVariable @Positive long commentId) { + feedUsecase.deleteComment(userId, commentId); + + return CommonResponse.success(ResponseCode.DELETE_COMMENT); + } } diff --git a/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java b/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java index 6055450d..5429db57 100644 --- a/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java +++ b/src/main/java/leets/leenk/domain/feed/presentation/ResponseCode.java @@ -25,7 +25,10 @@ public enum ResponseCode implements ResponseCodeInterface { DELETE_FEED(1211, HttpStatus.OK, "피드 삭제에 성공했습니다."), REPORT_FEED(1212, HttpStatus.OK, "피드 신고에 성공했습니다."), - GET_FEED_NAVIGATION(1213, HttpStatus.OK, "피드 네비게이션 조회에 성공했습니다."); + GET_FEED_NAVIGATION(1213, HttpStatus.OK, "피드 네비게이션 조회에 성공했습니다."), + + WRITE_COMMENT(1214, HttpStatus.OK, "댓글 작성에 성공했습니다."), + DELETE_COMMENT(1215, HttpStatus.OK, "댓글 삭제에 성공했습니다."); private final int code;