Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -7,7 +7,7 @@
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
@EnableScheduling
public class BackendApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.juniortown.backend.comment.controller;

import org.juniortown.backend.comment.dto.request.CommentCreateRequest;
import org.juniortown.backend.comment.dto.request.CommentUpdateRequest;
import org.juniortown.backend.comment.dto.response.CommentCreateResponse;
import org.juniortown.backend.comment.service.CommentService;
import org.juniortown.backend.user.dto.CustomUserDetails;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CommentController {
private final CommentService commentService;
@PostMapping("/comments")
public ResponseEntity<CommentCreateResponse> createComment(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@Valid @RequestBody CommentCreateRequest commentCreateRequest) {
Long userId = customUserDetails.getUserId();

CommentCreateResponse response = commentService.createComment(userId, commentCreateRequest);

return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@DeleteMapping("/comments/{commentId}")
public ResponseEntity<?> commentDeleteRequest(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable Long commentId) {
Long userId = customUserDetails.getUserId();
commentService.deleteComment(userId, commentId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@PatchMapping("/comments/{commentId}")
public ResponseEntity<?> commentUpdateRequest(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable Long commentId, @Valid @RequestBody CommentUpdateRequest commentUpdateRequest) {
Long userId = customUserDetails.getUserId();
commentService.updateComment(userId, commentId, commentUpdateRequest);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.juniortown.backend.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
public class CommentCreateRequest {
@NotNull(message = "게시글 ID를 입력해주세요.")
private final Long postId;
private final Long parentId;
@NotBlank(message = "댓글 내용을 입력해주세요.")
private final String content;

@Builder
public CommentCreateRequest(Long postId, Long parentId, String content) {
this.postId = postId;
this.parentId = parentId;
this.content = content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.juniortown.backend.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import lombok.Builder;
import lombok.Getter;

@Getter
public class CommentUpdateRequest {
@NotBlank(message = "댓글 내용을 입력해주세요.")
private final String content;
@Builder
public CommentUpdateRequest(String content) {
this.content = content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.juniortown.backend.comment.dto.response;

import java.time.LocalDateTime;

import org.juniortown.backend.comment.entity.Comment;

import lombok.Builder;
import lombok.Getter;

@Getter
public class CommentCreateResponse {
private final Long commentId;
private final String content;
private final Long postId;
private final Long parentId;
private final Long userId;
private final String username;
private final LocalDateTime createdAt;
@Builder
public CommentCreateResponse(Long commentId, String content, Long postId, Long parentId, Long userId, String username, LocalDateTime createdAt) {
this.commentId = commentId;
this.content = content;
this.postId = postId;
this.parentId = parentId;
this.userId = userId;
this.username = username;
this.createdAt = createdAt;
}
public static CommentCreateResponse from(Comment comment) {
return CommentCreateResponse.builder()
.commentId(comment.getId())
.content(comment.getContent())
.postId(comment.getPost().getId())
.parentId(comment.getParent() != null ? comment.getParent().getId() : null)
.userId(comment.getUser().getId())
.username(comment.getUser().getName())
.createdAt(comment.getCreatedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.juniortown.backend.comment.dto.response;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import org.juniortown.backend.comment.entity.Comment;

import lombok.Builder;
import lombok.Getter;

@Getter
public class CommentsInPost {
private final Long commentId;
private final Long postId;
private final Long parentId;
private final Long userId;
private final String content;
private final String username;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final LocalDateTime deletedAt;
private final List<CommentsInPost> children = new ArrayList<>();
@Builder
public CommentsInPost(Long commentId, Long postId, Long parentId, Long userId, String content, String username, LocalDateTime createdAt,
LocalDateTime updatedAt, LocalDateTime deletedAt) {
this.commentId = commentId;
this.postId = postId;
this.parentId = parentId;
this.userId = userId;
this.content = content;
this.username = username;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.deletedAt = deletedAt;
}

public static CommentsInPost from(Comment comment) {
return CommentsInPost.builder()
.commentId(comment.getId())
.postId(comment.getPost().getId())
// 근데 이게 꼭 필요한가? userId 쓰는 경우가 생각이 안나
.userId(comment.getUser().getId())
.parentId(comment.getParent() != null ? comment.getParent().getId() : null)
.content(comment.getContent())
// 이게 원래는 user 테이블을 조회해야하는데 comment에 username이 있으니까
// 조회를 안해도 되는 사기 스킬
.username(comment.getUsername())
.createdAt(comment.getCreatedAt())
.updatedAt(comment.getUpdatedAt())
.deletedAt(comment.getDeletedAt())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.juniortown.backend.comment.entity;

import java.time.Clock;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import org.juniortown.backend.comment.dto.request.CommentUpdateRequest;
import org.juniortown.backend.comment.exception.AlreadyDeletedCommentException;
import org.juniortown.backend.entity.BaseTimeEntity;
import org.juniortown.backend.post.entity.Post;
import org.juniortown.backend.user.entity.User;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne
@JoinColumn(name = "parent_id")
private Comment parent;
Comment on lines +37 to +39
Copy link

@coderabbitai coderabbitai bot Aug 5, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

순환 참조 가능성 검증 부재

댓글이 자기 자신을 부모로 가지거나 순환 참조가 발생할 수 있는 검증 로직이 없습니다.

서비스 레이어에서 부모 댓글 설정 시 다음과 같은 검증이 필요합니다:

  • 자기 자신을 부모로 설정하지 못하도록 검증
  • 순환 참조 체크 (A→B→C→A)
  • 최대 깊이 제한 확인 (PR 설명에 따르면 1단계까지만 허용)
public void validateParent(Comment parent) {
    if (parent != null) {
        if (parent.getId().equals(this.getId())) {
            throw new InvalidCommentHierarchyException("댓글은 자기 자신을 부모로 가질 수 없습니다.");
        }
        if (parent.getParent() != null) {
            throw new InvalidCommentHierarchyException("대댓글에는 답글을 달 수 없습니다.");
        }
    }
}
🤖 Prompt for AI Agents
In backend/src/main/java/org/juniortown/backend/comment/entity/Comment.java
around lines 37 to 39, add validation logic in the service layer when setting a
parent comment to prevent self-parenting, cyclic references, and enforce the
maximum depth of one level. Implement checks to ensure the parent is not the
comment itself, that the parent does not already have a parent (to avoid deeper
nesting), and throw appropriate exceptions if these conditions are violated.

Copy link
Owner Author

Choose a reason for hiding this comment

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

오 이부분을 검증하는 걸 생각 못했네 좀 찝찝했는데 잘 찝어줬네 라임 죽인다

Copy link

Choose a reason for hiding this comment

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

@Doncham 감사합니다! 🎯

댓글 시스템에서 순환 참조나 무한 깊이 문제는 나중에 발견하기 어려운 버그가 될 수 있어서 미리 잡아내는 것이 중요합니다. 특히 이번 PR에서 1단계 깊이만 허용한다고 하셨으니, 서비스 레이어에서 이런 검증 로직을 추가하시면 더 안전한 댓글 시스템이 될 것 같습니다.

코드 리뷰가 도움이 되었다니 기쁩니다! 😊


@Lob
private String content;

@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(name = "username", nullable = false)
private String username;

@ManyToOne
@JoinColumn(name = "post_id", nullable = false)
private Post post;

// 임시로 작성
@OneToMany(mappedBy = "parent")
private List<Comment> replies = new ArrayList<>();

@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Builder
public Comment(Comment parent, String content, User user,String username, Post post) {
this.parent = parent;
this.content = content;
this.user = user;
this.username = username;
this.post = post;
}

public void softDelete(Clock clock) {
if(this.deletedAt != null) {
throw new AlreadyDeletedCommentException();
}
this.deletedAt = LocalDateTime.now(clock);
}

public void update(CommentUpdateRequest commentUpdateRequest, Clock clock) {
if (this.deletedAt != null) {
throw new AlreadyDeletedCommentException();
}
this.content = commentUpdateRequest.getContent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;
import org.springframework.http.HttpStatus;

public class AlreadyDeletedCommentException extends CustomException {
public static final String MESSAGE = "이미 삭제된 댓글입니다.";

public AlreadyDeletedCommentException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return HttpStatus.NOT_FOUND.value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;
import org.springframework.http.HttpStatus;

public class CircularReferenceException extends CustomException {
public static final String MESSAGE = "댓글 순환 참조 발생!";
public CircularReferenceException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return HttpStatus.BAD_REQUEST.value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;

public class CommentNotFoundException extends CustomException {
public static final String MESSAGE = "해당 댓글을 찾을 수 없습니다.";

public CommentNotFoundException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return 404;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;
import org.springframework.http.HttpStatus;

public class DepthLimitTwoException extends CustomException {
public static final String MESSAGE = "댓글은 최대 2단계까지만 가능합니다.";

public DepthLimitTwoException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return HttpStatus.BAD_REQUEST.value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;
import org.springframework.http.HttpStatus;

public class NoRightForCommentDeleteException extends CustomException {
public static final String MESSAGE = "해당 댓글을 삭제할 권한이 없습니다.";

public NoRightForCommentDeleteException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return HttpStatus.FORBIDDEN.value();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.juniortown.backend.comment.exception;

import org.juniortown.backend.exception.CustomException;
import org.springframework.http.HttpStatus;

public class NoRightForCommentUpdateException extends CustomException {
public static final String MESSAGE = "해당 댓글을 수정할 권한이 없습니다.";

public NoRightForCommentUpdateException() {
super(MESSAGE);
}

@Override
public int getStatusCode() {
return HttpStatus.FORBIDDEN.value();
}
}
Loading