Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/main/java/org/sopt/article/entity/Article.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import lombok.*;
import org.hibernate.annotations.BatchSize;
import org.sopt.article.exception.ArticleException;
import org.sopt.comment.entity.Comment;
import org.sopt.member.entity.Member;
import org.sopt.util.entity.BaseEntity;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import static org.sopt.article.exception.ArticleErrorCode.*;
Expand Down Expand Up @@ -34,6 +37,10 @@ public class Article extends BaseEntity {
@Enumerated(EnumType.STRING)
private ArticleTag tag;

@Builder.Default
@OneToMany(mappedBy = "article", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

public static Article create(Member member, String title, String content, ArticleTag tag) {
validateMember(member);
validateTitle(title);
Expand Down
73 changes: 73 additions & 0 deletions src/main/java/org/sopt/comment/controller/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.sopt.comment.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.comment.controller.spec.CommentControllerDocs;
import org.sopt.comment.dto.request.CommentCreateRequest;
import org.sopt.comment.dto.request.CommentUpdateRequest;
import org.sopt.comment.dto.response.CommentResponse;
import org.sopt.comment.service.CommentService;
import org.sopt.util.BaseResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/articles/{articleId}/comments")
@RequiredArgsConstructor
public class CommentController implements CommentControllerDocs {

private final CommentService commentService;

@Override
@PostMapping
public ResponseEntity<BaseResponse<Long>> createComment(
@PathVariable Long articleId,
@Valid @RequestBody CommentCreateRequest request
) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(BaseResponse.onCreated(commentService.createComment(articleId, request)));
}

@Override
@GetMapping
public ResponseEntity<BaseResponse<List<CommentResponse>>> getCommentsByArticle(
@PathVariable Long articleId
) {
return ResponseEntity.ok(BaseResponse.onSuccess(commentService.findCommentsByArticleId(articleId)));
}

@Override
@GetMapping("/{commentId}")
public ResponseEntity<BaseResponse<CommentResponse>> getCommentById(
@PathVariable Long articleId,
@PathVariable Long commentId
) {
return ResponseEntity.ok(BaseResponse.onSuccess(commentService.findCommentById(articleId, commentId)));
}

@Override
@PutMapping("/{commentId}")
public ResponseEntity<BaseResponse<Void>> updateComment(
@PathVariable Long articleId,
@PathVariable Long commentId,
@Valid @RequestBody CommentUpdateRequest request
) {
commentService.updateComment(articleId, commentId, request);
return ResponseEntity.ok(BaseResponse.onSuccess());
}

@Override
@DeleteMapping("/{commentId}")
public ResponseEntity<BaseResponse<Void>> deleteComment(
@PathVariable Long articleId,
@PathVariable Long commentId,
@RequestParam Long memberId
) {
commentService.deleteComment(articleId, commentId, memberId);
return ResponseEntity.ok(BaseResponse.onSuccess());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package org.sopt.comment.controller.spec;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.sopt.comment.dto.request.CommentCreateRequest;
import org.sopt.comment.dto.request.CommentUpdateRequest;
import org.sopt.comment.dto.response.CommentResponse;
import org.sopt.util.BaseResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Tag(name = "Comment", description = "댓글 관리 API")
public interface CommentControllerDocs {

@Operation(summary = "댓글 생성", description = "게시글에 새로운 댓글을 작성합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "댓글 생성 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "404", description = "게시글 또는 작성자를 찾을 수 없음", content = @Content)
})
ResponseEntity<BaseResponse<Long>> createComment(
@Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId,
@Valid @RequestBody CommentCreateRequest request
);

@Operation(summary = "댓글 목록 조회", description = "게시글의 모든 댓글을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "게시글을 찾을 수 없음", content = @Content)
})
ResponseEntity<BaseResponse<List<CommentResponse>>> getCommentsByArticle(
@Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId
);

@Operation(summary = "댓글 상세 조회", description = "특정 댓글을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "게시글 또는 댓글을 찾을 수 없음", content = @Content)
})
ResponseEntity<BaseResponse<CommentResponse>> getCommentById(
@Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId,
@Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId
);

@Operation(summary = "댓글 수정", description = "댓글 내용을 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
@ApiResponse(responseCode = "403", description = "수정 권한 없음", content = @Content),
@ApiResponse(responseCode = "404", description = "게시글 또는 댓글을 찾을 수 없음", content = @Content)
})
ResponseEntity<BaseResponse<Void>> updateComment(
@Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId,
@Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId,
@Valid @RequestBody CommentUpdateRequest request
);

@Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "삭제 성공"),
@ApiResponse(responseCode = "403", description = "삭제 권한 없음", content = @Content),
@ApiResponse(responseCode = "404", description = "게시글 또는 댓글을 찾을 수 없음", content = @Content)
})
ResponseEntity<BaseResponse<Void>> deleteComment(
@Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId,
@Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId,
@Parameter(description = "작성자 ID", required = true) @RequestParam Long memberId
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.sopt.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record CommentCreateRequest(
@NotNull(message = "작성자 ID는 필수 입력 항목입니다.")
Long memberId,

@NotBlank(message = "댓글 내용은 필수 입력 항목입니다.")
@Size(min = 1, max = 1000, message = "댓글은 최소 1자 이상, 최대 1000자까지 입력 가능합니다.")
String content
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.sopt.comment.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record CommentUpdateRequest(
@NotNull(message = "작성자 ID는 필수 입력 항목입니다.")
Long memberId,

@NotBlank(message = "댓글 내용은 필수 입력 항목입니다.")
@Size(min = 1, max = 1000, message = "댓글은 최소 1자 이상, 최대 1000자까지 입력 가능합니다.")
String content
) {
}
23 changes: 23 additions & 0 deletions src/main/java/org/sopt/comment/dto/response/CommentResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.sopt.comment.dto.response;

import org.sopt.comment.entity.Comment;

import java.time.LocalDateTime;

public record CommentResponse(
Long id,
String content,
String authorName,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static CommentResponse from(Comment comment) {
return new CommentResponse(
comment.getId(),
comment.getContent(),
comment.getMember().getName(),
comment.getCreatedAt(),
comment.getUpdatedAt()
);
}
}
103 changes: 103 additions & 0 deletions src/main/java/org/sopt/comment/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package org.sopt.comment.entity;

import jakarta.persistence.*;
import lombok.*;
import org.sopt.article.entity.Article;
import org.sopt.comment.exception.CommentException;
import org.sopt.member.entity.Member;
import org.sopt.util.entity.BaseEntity;

import java.util.Objects;

import static org.sopt.comment.exception.CommentErrorCode.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class Comment extends BaseEntity {

private static final int MAX_CONTENT_LENGTH = 300;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "article_id", nullable = false)
private Article article;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@Column(nullable = false, length = MAX_CONTENT_LENGTH)
private String content;

public static Comment create(Article article, Member member, String content) {
validateArticle(article);
validateMember(member);
validateContent(content);

return Comment.builder()
.article(article)
.member(member)
.content(content)
.build();
}

public void updateContent(String content) {
validateContent(content);
this.content = content;
}

public void validateOwnership(Long memberId) {
if (!this.member.getId().equals(memberId)) {
throw new CommentException(COMMENT_UNAUTHORIZED);
}
}

public void validateBelongsToArticle(Long articleId) {
if (!this.article.getId().equals(articleId)) {
throw new CommentException(COMMENT_ARTICLE_MISMATCH);
}
}

private static void validateArticle(Article article) {
if (article == null) {
throw new CommentException(COMMENT_ARTICLE_REQUIRED);
}
}

private static void validateMember(Member member) {
if (member == null) {
throw new CommentException(COMMENT_MEMBER_REQUIRED);
}
}

private static void validateContent(String content) {
if (content == null || content.trim().isEmpty()) {
throw new CommentException(COMMENT_CONTENT_REQUIRED);
}
if (content.length() > MAX_CONTENT_LENGTH) {
throw new CommentException(COMMENT_CONTENT_TOO_LONG);
}
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Comment comment)) {
return false;
}
return this.id != null && this.id.equals(comment.id);
}

@Override
public int hashCode() {
return Objects.hash(this.id);
}
}
48 changes: 48 additions & 0 deletions src/main/java/org/sopt/comment/exception/CommentErrorCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package org.sopt.comment.exception;

import org.sopt.util.exception.ErrorCode;
import org.springframework.http.HttpStatus;

public enum CommentErrorCode implements ErrorCode {

COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_NOT_FOUND", "해당 ID의 댓글을 찾을 수 없습니다."),
COMMENT_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_ARTICLE_NOT_FOUND", "댓글을 작성할 게시글을 찾을 수 없습니다."),
COMMENT_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_MEMBER_NOT_FOUND", "댓글 작성자를 찾을 수 없습니다."),
COMMENT_UNAUTHORIZED(HttpStatus.FORBIDDEN, "COMMENT_UNAUTHORIZED", "댓글을 수정/삭제할 권한이 없습니다."),
COMMENT_ARTICLE_MISMATCH(HttpStatus.BAD_REQUEST, "COMMENT_ARTICLE_MISMATCH", "해당 게시글의 댓글이 아닙니다."),

COMMENT_ARTICLE_REQUIRED(HttpStatus.BAD_REQUEST, "COMMENT_ARTICLE_REQUIRED", "게시글은 필수 입력 항목입니다."),
COMMENT_MEMBER_REQUIRED(HttpStatus.BAD_REQUEST, "COMMENT_MEMBER_REQUIRED", "작성자는 필수 입력 항목입니다."),
COMMENT_CONTENT_REQUIRED(HttpStatus.BAD_REQUEST, "COMMENT_CONTENT_REQUIRED", "댓글 내용은 필수 입력 항목입니다."),
COMMENT_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "COMMENT_CONTENT_TOO_LONG", "댓글은 최대 1000자까지 입력 가능합니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

CommentErrorCode(HttpStatus httpStatus, String code, String message) {
this.httpStatus = httpStatus;
this.code = code;
this.message = message;
}

@Override
public HttpStatus getHttpStatus() {
return httpStatus;
}

@Override
public String getCode() {
return code;
}

@Override
public String getMessage() {
return message;
}

@Override
public String format(Object... args) {
return String.format(message, args);
}
}
Loading