diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/api/CommentController.java b/src/main/java/com/depromeet/stonebed/domain/comment/api/CommentController.java new file mode 100644 index 00000000..69c45b83 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/api/CommentController.java @@ -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 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); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/application/CommentService.java b/src/main/java/com/depromeet/stonebed/domain/comment/application/CommentService.java new file mode 100644 index 00000000..fed5b6b1 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/application/CommentService.java @@ -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 allComments = + commentRepository.findAllCommentsByMissionRecord(missionRecord); + + // 댓글을 부모 ID로 그룹화, 부모 ID가 null인 경우 -1L로 처리 + Map> commentsByParentId = + allComments.stream() + .collect( + Collectors.groupingBy( + comment -> { + Comment parent = comment.getParent(); + return (parent != null) + ? parent.getId() + : ROOT_COMMENT_PARENT_ID; + }, + Collectors.toList())); + + // 부모 댓글 (부모 댓글이 없는 댓글) 조회 + List rootComments = + commentsByParentId.getOrDefault(ROOT_COMMENT_PARENT_ID, List.of()); + + // 부모 댓글을 CommentFindOneResponse로 변환 + List 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> commentsByParentId) { + List 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)); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepository.java b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepository.java index e211a076..c2763a03 100644 --- a/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepository.java +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepository.java @@ -3,4 +3,4 @@ import com.depromeet.stonebed.domain.comment.domain.Comment; import org.springframework.data.jpa.repository.JpaRepository; -public interface CommentRepository extends JpaRepository {} +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom {} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryCustom.java b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryCustom.java new file mode 100644 index 00000000..7d1cb6ac --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryCustom.java @@ -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 findAllCommentsByMissionRecord(MissionRecord missionRecord); +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryImpl.java b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryImpl.java new file mode 100644 index 00000000..9d7912ed --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dao/CommentRepositoryImpl.java @@ -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 findAllCommentsByMissionRecord(MissionRecord missionRecord) { + return queryFactory + .selectFrom(comment) + .leftJoin(comment.parent) + .fetchJoin() + .leftJoin(comment.replyComments) + .fetchJoin() + .where(comment.missionRecord.eq(missionRecord)) + .fetch(); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/domain/Comment.java b/src/main/java/com/depromeet/stonebed/domain/comment/domain/Comment.java index 70944d8d..cd6dc663 100644 --- a/src/main/java/com/depromeet/stonebed/domain/comment/domain/Comment.java +++ b/src/main/java/com/depromeet/stonebed/domain/comment/domain/Comment.java @@ -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; @@ -52,7 +53,7 @@ public class Comment extends BaseTimeEntity { // 자식 댓글 @OneToMany(mappedBy = "parent", orphanRemoval = true) - private List children = new ArrayList<>(); + private List replyComments = new ArrayList<>(); @Builder(access = AccessLevel.PRIVATE) public Comment(MissionRecord missionRecord, Member writer, String content, Comment parent) { @@ -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) diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dto/request/CommentCreateRequest.java b/src/main/java/com/depromeet/stonebed/domain/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 00000000..4b0e2d43 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dto/request/CommentCreateRequest.java @@ -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) {} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentCreateResponse.java b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentCreateResponse.java new file mode 100644 index 00000000..d6ebf760 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentCreateResponse.java @@ -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); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindOneResponse.java b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindOneResponse.java new file mode 100644 index 00000000..5f5161d0 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindOneResponse.java @@ -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 replyComments) { + public static CommentFindOneResponse of( + Long parentId, + Long commentId, + String content, + Long writerId, + String writerNickname, + String writerProfileImageUrl, + String createdAt, + List replyComments) { + return new CommentFindOneResponse( + parentId, + commentId, + content, + writerId, + writerNickname, + writerProfileImageUrl, + createdAt, + replyComments); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindResponse.java b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindResponse.java new file mode 100644 index 00000000..56a747d1 --- /dev/null +++ b/src/main/java/com/depromeet/stonebed/domain/comment/dto/response/CommentFindResponse.java @@ -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 comments) { + public static CommentFindResponse of(List comments) { + return new CommentFindResponse(comments); + } +} diff --git a/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java b/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java index 2abf384f..37828d78 100644 --- a/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java +++ b/src/main/java/com/depromeet/stonebed/domain/member/api/MemberController.java @@ -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입니다.") @@ -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() { diff --git a/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java b/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java index a0925b03..177d11cc 100644 --- a/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java +++ b/src/main/java/com/depromeet/stonebed/domain/member/application/MemberService.java @@ -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); + } } diff --git a/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java b/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java index b30050ca..c2fab75b 100644 --- a/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java +++ b/src/main/java/com/depromeet/stonebed/domain/member/dao/MemberRepository.java @@ -7,4 +7,6 @@ public interface MemberRepository extends JpaRepository, MemberRepositoryCustom { Optional findByOauthInfoOauthProviderAndOauthInfoOauthId( String oauthProvider, String oauthId); + + Optional findByProfileNickname(String nickname); } diff --git a/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java index dd76c07f..4538396c 100644 --- a/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java +++ b/src/main/java/com/depromeet/stonebed/global/error/ErrorCode.java @@ -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; } diff --git a/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java b/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java index de548731..9b2a2d40 100644 --- a/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java +++ b/src/main/java/com/depromeet/stonebed/global/util/MemberUtil.java @@ -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)); + } }