diff --git a/src/main/java/org/sopt/article/entity/Article.java b/src/main/java/org/sopt/article/entity/Article.java index 0d1339f..b66c4d2 100644 --- a/src/main/java/org/sopt/article/entity/Article.java +++ b/src/main/java/org/sopt/article/entity/Article.java @@ -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.*; @@ -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 comments = new ArrayList<>(); + public static Article create(Member member, String title, String content, ArticleTag tag) { validateMember(member); validateTitle(title); diff --git a/src/main/java/org/sopt/comment/controller/CommentController.java b/src/main/java/org/sopt/comment/controller/CommentController.java new file mode 100644 index 0000000..b55d406 --- /dev/null +++ b/src/main/java/org/sopt/comment/controller/CommentController.java @@ -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> createComment( + @PathVariable Long articleId, + @Valid @RequestBody CommentCreateRequest request + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(BaseResponse.onCreated(commentService.createComment(articleId, request))); + } + + @Override + @GetMapping + public ResponseEntity>> getCommentsByArticle( + @PathVariable Long articleId + ) { + return ResponseEntity.ok(BaseResponse.onSuccess(commentService.findCommentsByArticleId(articleId))); + } + + @Override + @GetMapping("/{commentId}") + public ResponseEntity> getCommentById( + @PathVariable Long articleId, + @PathVariable Long commentId + ) { + return ResponseEntity.ok(BaseResponse.onSuccess(commentService.findCommentById(articleId, commentId))); + } + + @Override + @PutMapping("/{commentId}") + public ResponseEntity> 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> deleteComment( + @PathVariable Long articleId, + @PathVariable Long commentId, + @RequestParam Long memberId + ) { + commentService.deleteComment(articleId, commentId, memberId); + return ResponseEntity.ok(BaseResponse.onSuccess()); + } +} diff --git a/src/main/java/org/sopt/comment/controller/spec/CommentControllerDocs.java b/src/main/java/org/sopt/comment/controller/spec/CommentControllerDocs.java new file mode 100644 index 0000000..404d9d1 --- /dev/null +++ b/src/main/java/org/sopt/comment/controller/spec/CommentControllerDocs.java @@ -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> 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>> getCommentsByArticle( + @Parameter(description = "게시글 ID", required = true) @PathVariable Long articleId + ); + + @Operation(summary = "댓글 상세 조회", description = "특정 댓글을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "게시글 또는 댓글을 찾을 수 없음", content = @Content) + }) + ResponseEntity> 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> 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> 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 + ); +} diff --git a/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..ec5ab88 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/request/CommentCreateRequest.java @@ -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 +) { +} diff --git a/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java b/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java new file mode 100644 index 0000000..b249c08 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/request/CommentUpdateRequest.java @@ -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 +) { +} diff --git a/src/main/java/org/sopt/comment/dto/response/CommentResponse.java b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java new file mode 100644 index 0000000..2d86024 --- /dev/null +++ b/src/main/java/org/sopt/comment/dto/response/CommentResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/org/sopt/comment/entity/Comment.java b/src/main/java/org/sopt/comment/entity/Comment.java new file mode 100644 index 0000000..9f58782 --- /dev/null +++ b/src/main/java/org/sopt/comment/entity/Comment.java @@ -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); + } +} diff --git a/src/main/java/org/sopt/comment/exception/CommentErrorCode.java b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..eb193bd --- /dev/null +++ b/src/main/java/org/sopt/comment/exception/CommentErrorCode.java @@ -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); + } +} diff --git a/src/main/java/org/sopt/comment/exception/CommentException.java b/src/main/java/org/sopt/comment/exception/CommentException.java new file mode 100644 index 0000000..6a3f6d1 --- /dev/null +++ b/src/main/java/org/sopt/comment/exception/CommentException.java @@ -0,0 +1,18 @@ +package org.sopt.comment.exception; + +import org.sopt.util.exception.ErrorCode; +import org.sopt.util.exception.GeneralException; + +public class CommentException extends GeneralException { + public CommentException(ErrorCode errorCode) { + super(errorCode); + } + + public CommentException(ErrorCode errorCode, Object... args) { + super(errorCode, args); + } + + public CommentException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/src/main/java/org/sopt/comment/repository/CommentRepository.java b/src/main/java/org/sopt/comment/repository/CommentRepository.java new file mode 100644 index 0000000..b1f23ed --- /dev/null +++ b/src/main/java/org/sopt/comment/repository/CommentRepository.java @@ -0,0 +1,15 @@ +package org.sopt.comment.repository; + +import org.sopt.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + + List findByArticleIdOrderByCreatedAtDesc(Long articleId); + + boolean existsByIdAndArticleId(Long id, Long articleId); + + void deleteAllByArticleId(Long articleId); +} diff --git a/src/main/java/org/sopt/comment/repository/CommentRepositoryCustom.java b/src/main/java/org/sopt/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 0000000..cf88bd3 --- /dev/null +++ b/src/main/java/org/sopt/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,10 @@ +package org.sopt.comment.repository; + +import org.sopt.comment.dto.response.CommentResponse; + +import java.util.List; + +public interface CommentRepositoryCustom { + + List findAllByArticleId(Long articleId); +} diff --git a/src/main/java/org/sopt/comment/repository/CommentRepositoryCustomImpl.java b/src/main/java/org/sopt/comment/repository/CommentRepositoryCustomImpl.java new file mode 100644 index 0000000..a6784d2 --- /dev/null +++ b/src/main/java/org/sopt/comment/repository/CommentRepositoryCustomImpl.java @@ -0,0 +1,34 @@ +package org.sopt.comment.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.sopt.comment.dto.response.CommentResponse; + +import java.util.List; + +import static org.sopt.comment.entity.QComment.comment; +import static org.sopt.member.entity.QMember.member; + +@RequiredArgsConstructor +public class CommentRepositoryCustomImpl implements CommentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByArticleId(Long articleId) { + return queryFactory + .select(Projections.constructor(CommentResponse.class, + comment.id, + comment.content, + member.name, + comment.createdAt, + comment.updatedAt + )) + .from(comment) + .join(comment.member, member) + .where(comment.article.id.eq(articleId)) + .orderBy(comment.createdAt.desc()) + .fetch(); + } +} diff --git a/src/main/java/org/sopt/comment/service/CommentService.java b/src/main/java/org/sopt/comment/service/CommentService.java new file mode 100644 index 0000000..39d1e6c --- /dev/null +++ b/src/main/java/org/sopt/comment/service/CommentService.java @@ -0,0 +1,89 @@ +package org.sopt.comment.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.article.entity.Article; +import org.sopt.article.exception.ArticleErrorCode; +import org.sopt.article.exception.ArticleException; +import org.sopt.article.repository.ArticleRepository; +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.entity.Comment; +import org.sopt.comment.exception.CommentErrorCode; +import org.sopt.comment.exception.CommentException; +import org.sopt.comment.repository.CommentRepository; +import org.sopt.member.entity.Member; +import org.sopt.member.repository.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final ArticleRepository articleRepository; + private final MemberRepository memberRepository; + + @Transactional + public Long createComment(Long articleId, CommentCreateRequest request) { + Article article = findArticleById(articleId); + Member member = findMemberById(request.memberId()); + + Comment comment = Comment.create(article, member, request.content()); + return commentRepository.save(comment).getId(); + } + + public List findCommentsByArticleId(Long articleId) { + validateArticleExists(articleId); + return commentRepository.findAllByArticleId(articleId); + } + + public CommentResponse findCommentById(Long articleId, Long commentId) { + Comment comment = findCommentAndValidateArticle(articleId, commentId); + return CommentResponse.from(comment); + } + + @Transactional + public void updateComment(Long articleId, Long commentId, CommentUpdateRequest request) { + Comment comment = findCommentAndValidateArticle(articleId, commentId); + comment.validateOwnership(request.memberId()); + comment.updateContent(request.content()); + } + + @Transactional + public void deleteComment(Long articleId, Long commentId, Long memberId) { + Comment comment = findCommentAndValidateArticle(articleId, commentId); + comment.validateOwnership(memberId); + commentRepository.delete(comment); + } + + private Article findArticleById(Long articleId) { + return articleRepository.findById(articleId) + .orElseThrow(() -> new CommentException(CommentErrorCode.COMMENT_ARTICLE_NOT_FOUND)); + } + + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new CommentException(CommentErrorCode.COMMENT_MEMBER_NOT_FOUND)); + } + + private void validateArticleExists(Long articleId) { + if (!articleRepository.existsById(articleId)) { + throw new ArticleException(ArticleErrorCode.ARTICLE_NOT_FOUND); + } + } + + private Comment findCommentAndValidateArticle(Long articleId, Long commentId) { + validateArticleExists(articleId); + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentException(CommentErrorCode.COMMENT_NOT_FOUND)); + + comment.validateBelongsToArticle(articleId); + return comment; + } +}