Skip to content

Commit

Permalink
feat: 댓글 조회 및 댓글, 대댓글 추가 기능 구현 (#287)
Browse files Browse the repository at this point in the history
* feat: comment API 중간 커밋

* feat: 댓글 추가 및 조회

* fix: DTO Swagger 정의

* fix: children -> replyComments

* refactor: 개선 및 주석

* fix: @dbscks97 리뷰 반영
  • Loading branch information
char-yb authored Oct 1, 2024
1 parent 37d2457 commit 5b85d1e
Show file tree
Hide file tree
Showing 15 changed files with 321 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.depromeet.stonebed.domain.comment.api;

import com.depromeet.stonebed.domain.comment.application.CommentService;
import com.depromeet.stonebed.domain.comment.dto.request.CommentCreateRequest;
import com.depromeet.stonebed.domain.comment.dto.response.CommentCreateResponse;
import com.depromeet.stonebed.domain.comment.dto.response.CommentFindResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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;

@Tag(name = "9. [댓글]", description = "댓글 관련 API입니다.")
@RequestMapping("/comments")
@RestController
@RequiredArgsConstructor
public class CommentController {

private final CommentService commentService;

@Operation(summary = "댓글 작성", description = "댓글을 작성합니다.")
@PostMapping
public ResponseEntity<CommentCreateResponse> commentCreate(
@RequestBody @Valid CommentCreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(commentService.createComment(request));
}

@Operation(summary = "댓글 조회", description = "댓글을 조회합니다.")
@GetMapping
public CommentFindResponse commentFind(@RequestParam Long recordId) {
return commentService.findCommentsByRecordId(recordId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.depromeet.stonebed.domain.comment.application;

import com.depromeet.stonebed.domain.comment.dao.CommentRepository;
import com.depromeet.stonebed.domain.comment.domain.Comment;
import com.depromeet.stonebed.domain.comment.dto.request.CommentCreateRequest;
import com.depromeet.stonebed.domain.comment.dto.response.CommentCreateResponse;
import com.depromeet.stonebed.domain.comment.dto.response.CommentFindOneResponse;
import com.depromeet.stonebed.domain.comment.dto.response.CommentFindResponse;
import com.depromeet.stonebed.domain.member.domain.Member;
import com.depromeet.stonebed.domain.missionRecord.dao.MissionRecordRepository;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord;
import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import com.depromeet.stonebed.global.util.MemberUtil;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

private final MemberUtil memberUtil;
private final CommentRepository commentRepository;
private final MissionRecordRepository missionRecordRepository;
private static final Long ROOT_COMMENT_PARENT_ID = -1L;

/**
* 댓글을 생성합니다.
*
* @param request 댓글 생성 요청 객체 (content, recordId, parentId 포함)
* @return 생성된 댓글의 ID를 포함한 응답 객체
*/
public CommentCreateResponse createComment(CommentCreateRequest request) {
final Member member = memberUtil.getCurrentMember();
final MissionRecord missionRecord = findMissionRecordById(request.recordId());

// 부모 댓글이 존재하는 경우

final Comment comment =
request.parentId() != null
? Comment.createComment(
missionRecord,
member,
request.content(),
findCommentById(request.parentId()))
: Comment.createComment(missionRecord, member, request.content(), null);

Comment savedComment = commentRepository.save(comment);

return CommentCreateResponse.of(savedComment.getId());
}

/**
* 특정 기록 ID에 대한 모든 댓글을 조회합니다.
*
* @param recordId 조회할 기록의 ID
* @return 조회된 댓글 목록을 포함한 응답 객체
*/
@Transactional(readOnly = true)
public CommentFindResponse findCommentsByRecordId(Long recordId) {
final MissionRecord missionRecord = findMissionRecordById(recordId);
final List<Comment> allComments =
commentRepository.findAllCommentsByMissionRecord(missionRecord);

// 댓글을 부모 ID로 그룹화, 부모 ID가 null인 경우 -1L로 처리
Map<Long, List<Comment>> commentsByParentId =
allComments.stream()
.collect(
Collectors.groupingBy(
comment -> {
Comment parent = comment.getParent();
return (parent != null)
? parent.getId()
: ROOT_COMMENT_PARENT_ID;
},
Collectors.toList()));

// 부모 댓글 (부모 댓글이 없는 댓글) 조회
List<Comment> rootComments =
commentsByParentId.getOrDefault(ROOT_COMMENT_PARENT_ID, List.of());

// 부모 댓글을 CommentFindOneResponse로 변환
List<CommentFindOneResponse> rootResponses =
rootComments.stream()
.map(
comment ->
convertToCommentFindOneResponse(
comment, commentsByParentId))
.collect(Collectors.toList());

return CommentFindResponse.of(rootResponses);
}

/**
* Comment 객체를 CommentFindOneResponse 객체로 변환합니다.
*
* @param comment 변환할 댓글 객체
* @param commentsByParentId 부모 ID로 그룹화된 댓글 key-value 형태의 Map
* @return 변환된 댓글 응답 객체
*/
private CommentFindOneResponse convertToCommentFindOneResponse(
Comment comment, Map<Long, List<Comment>> commentsByParentId) {
List<CommentFindOneResponse> replyCommentsResponses =
commentsByParentId.getOrDefault(comment.getId(), List.of()).stream()
.map(
childComment ->
convertToCommentFindOneResponse(
childComment, commentsByParentId))
.collect(Collectors.toList());

return CommentFindOneResponse.of(
comment.getParent() != null ? comment.getParent().getId() : null,
comment.getId(),
comment.getContent(),
comment.getWriter().getId(),
comment.getWriter().getProfile().getNickname(),
comment.getWriter().getProfile().getProfileImageUrl(),
comment.getCreatedAt().toString(),
replyCommentsResponses);
}

/**
* MissionRecord를 조회
*
* @param recordId 조회할 기록의 ID
* @return 조회된 MissionRecord 객체
* @throws CustomException 기록을 찾을 수 없는 경우 예외 발생
*/
private MissionRecord findMissionRecordById(Long recordId) {
return missionRecordRepository
.findById(recordId)
.orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND));
}

/**
* Comment를 조회
*
* @param commentId 조회할 댓글의 ID
* @return 조회된 Comment 객체
* @throws CustomException 댓글을 찾을 수 없는 경우 예외 발생
*/
private Comment findCommentById(Long commentId) {
return commentRepository
.findById(commentId)
.orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
import com.depromeet.stonebed.domain.comment.domain.Comment;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CommentRepository extends JpaRepository<Comment, Long> {}
public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.stonebed.domain.comment.dao;

import com.depromeet.stonebed.domain.comment.domain.Comment;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord;
import java.util.List;

public interface CommentRepositoryCustom {
List<Comment> findAllCommentsByMissionRecord(MissionRecord missionRecord);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.depromeet.stonebed.domain.comment.dao;

import static com.depromeet.stonebed.domain.comment.domain.QComment.comment;

import com.depromeet.stonebed.domain.comment.domain.Comment;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class CommentRepositoryImpl implements CommentRepositoryCustom {

private final JPAQueryFactory queryFactory;

@Override
public List<Comment> findAllCommentsByMissionRecord(MissionRecord missionRecord) {
return queryFactory
.selectFrom(comment)
.leftJoin(comment.parent)
.fetchJoin()
.leftJoin(comment.replyComments)
.fetchJoin()
.where(comment.missionRecord.eq(missionRecord))
.fetch();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.depromeet.stonebed.domain.member.domain.Member;
import com.depromeet.stonebed.domain.missionRecord.domain.MissionRecord;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
Expand Down Expand Up @@ -52,7 +53,7 @@ public class Comment extends BaseTimeEntity {

// 자식 댓글
@OneToMany(mappedBy = "parent", orphanRemoval = true)
private List<Comment> children = new ArrayList<>();
private List<Comment> replyComments = new ArrayList<>();

@Builder(access = AccessLevel.PRIVATE)
public Comment(MissionRecord missionRecord, Member writer, String content, Comment parent) {
Expand All @@ -63,7 +64,7 @@ public Comment(MissionRecord missionRecord, Member writer, String content, Comme
}

public static Comment createComment(
MissionRecord missionRecord, Member writer, String content, Comment parent) {
MissionRecord missionRecord, Member writer, String content, @Nullable Comment parent) {
return Comment.builder()
.missionRecord(missionRecord)
.writer(writer)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.depromeet.stonebed.domain.comment.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;

public record CommentCreateRequest(
@Schema(description = "댓글 내용", example = "너무 이쁘자나~") String content,
@Schema(description = "기록 ID", example = "1") Long recordId,
@Schema(description = "부모 댓글 ID", example = "1") Long parentId) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.stonebed.domain.comment.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record CommentCreateResponse(@Schema(description = "댓글 ID", example = "1") Long commentId) {
public static CommentCreateResponse of(Long commentId) {
return new CommentCreateResponse(commentId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.depromeet.stonebed.domain.comment.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record CommentFindOneResponse(
@Schema(description = "부모 댓글 ID", example = "1") Long parentId,
@Schema(description = "댓글 ID", example = "1") Long commentId,
@Schema(description = "댓글 내용", example = "너무 이쁘자나~") String content,
@Schema(description = "작성자 ID", example = "1") Long writerId,
@Schema(description = "작성자 닉네임", example = "왈왈대장") String writerNickname,
@Schema(description = "작성자 프로필 이미지 URL", example = "https://default.walwal/profile.jpg")
String writerProfileImageUrl,
@Schema(description = "작성일", example = "2021-10-01T00:00:00") String createdAt,
@Schema(description = "자식 댓글 목록") List<CommentFindOneResponse> replyComments) {
public static CommentFindOneResponse of(
Long parentId,
Long commentId,
String content,
Long writerId,
String writerNickname,
String writerProfileImageUrl,
String createdAt,
List<CommentFindOneResponse> replyComments) {
return new CommentFindOneResponse(
parentId,
commentId,
content,
writerId,
writerNickname,
writerProfileImageUrl,
createdAt,
replyComments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.depromeet.stonebed.domain.comment.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record CommentFindResponse(
@Schema(description = "댓글 목록") List<CommentFindOneResponse> comments) {
public static CommentFindResponse of(List<CommentFindOneResponse> comments) {
return new CommentFindResponse(comments);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.PutMapping;
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;

@Tag(name = "1-2. [회원]", description = "회원 관련 API입니다.")
Expand All @@ -25,6 +26,12 @@ public class MemberController {

private final MemberService memberService;

@Operation(summary = "회원 정보 조회", description = "회원 정보를 조회하는 API입니다.")
@GetMapping
public MemberInfoResponse memberInfoByNickname(@Valid @RequestParam String nickname) {
return memberService.findMemberInfoByNickname(nickname);
}

@Operation(summary = "내 정보 조회", description = "내 정보를 조회하는 API입니다.")
@GetMapping("/me")
public MemberInfoResponse memberMyInfo() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ public void modifyMemberProfile(MemberProfileUpdateRequest request) {
Profile profile = Profile.createProfile(request.nickname(), request.profileImageUrl());
member.updateProfile(profile);
}

public MemberInfoResponse findMemberInfoByNickname(String nickname) {
Member member = memberUtil.getMemberByNickname(nickname);
return MemberInfoResponse.from(member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
Optional<Member> findByOauthInfoOauthProviderAndOauthInfoOauthId(
String oauthProvider, String oauthId);

Optional<Member> findByProfileNickname(String nickname);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@ public enum ErrorCode {
NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 알림을 찾을 수 없습니다."),

// report
INVALID_REPORT_REASON(HttpStatus.NOT_FOUND, "해당 신고 사유를 찾을 수 없습니다.");
INVALID_REPORT_REASON(HttpStatus.NOT_FOUND, "해당 신고 사유를 찾을 수 없습니다."),

// comment
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."),
;
private final HttpStatus httpStatus;
private final String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ private void validateNicknameNotDuplicate(String nickname, String currentNicknam
throw new CustomException(ErrorCode.MEMBER_ALREADY_NICKNAME);
}
}

public Member getMemberByNickname(String nickname) {
return memberRepository
.findByProfileNickname(nickname)
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
}
}

0 comments on commit 5b85d1e

Please sign in to comment.