diff --git a/build.gradle b/build.gradle index 39081ac..84dd4ea 100644 --- a/build.gradle +++ b/build.gradle @@ -29,4 +29,8 @@ dependencies { // mysql runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'com.auth0:java-jwt:4.4.0' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' } \ No newline at end of file diff --git a/src/main/java/org/sopt/config/SwaggerConfig.java b/src/main/java/org/sopt/config/SwaggerConfig.java new file mode 100644 index 0000000..375fdc6 --- /dev/null +++ b/src/main/java/org/sopt/config/SwaggerConfig.java @@ -0,0 +1,32 @@ +package org.sopt.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("SOPT API") + .description("SOPT 과제 API 문서") + .version("v1.0.0") + .contact(new Contact() + .name("byunheemin") + .email("byunhm02@gmail.com"))) + .servers(List.of( + new Server() + .url("http://localhost:8080") + .description("로컬 서버") + )); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/controller/CommentController.java b/src/main/java/org/sopt/controller/CommentController.java new file mode 100644 index 0000000..9571e32 --- /dev/null +++ b/src/main/java/org/sopt/controller/CommentController.java @@ -0,0 +1,80 @@ +package org.sopt.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sopt.dto.CommentResponseDto; +import org.sopt.dto.CreateCommentRequestDto; +import org.sopt.dto.UpdateCommentRequestDto; +import org.sopt.global.ApiResponseDto; +import org.sopt.service.CommentService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // 댓글 작성 + @PostMapping + public ResponseEntity> createComment( + @Valid @RequestBody CreateCommentRequestDto request) { + + Long commentId = commentService.createComment(request); + ApiResponseDto response = ApiResponseDto.success(commentId); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + // 댓글 단건 조회 + @GetMapping("/{id}") + public ResponseEntity> getComment( + @PathVariable Long id) { + + CommentResponseDto comment = commentService.getComment(id); + ApiResponseDto response = ApiResponseDto.success(comment); + + return ResponseEntity.ok(response); + } + + // 특정 게시글의 모든 댓글 조회 + @GetMapping("/article/{articleId}") + public ResponseEntity>> getCommentsByArticle( + @PathVariable Long articleId) { + + List comments = commentService.getCommentsByArticle(articleId); + ApiResponseDto> response = ApiResponseDto.success(comments); + + return ResponseEntity.ok(response); + } + + // 댓글 수정 + @PatchMapping("/{id}") + public ResponseEntity> updateComment( + @PathVariable Long id, + @RequestParam Long memberId, + @Valid @RequestBody UpdateCommentRequestDto request) { + + commentService.updateComment(id, memberId, request); + ApiResponseDto response = ApiResponseDto.success(null); + + return ResponseEntity.ok(response); + } + + // 댓글 삭제 + @DeleteMapping("/{id}") + public ResponseEntity> deleteComment( + @PathVariable Long id, + @RequestParam Long memberId) { + + commentService.deleteComment(id, memberId); + ApiResponseDto response = ApiResponseDto.success(null); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/domain/Article.java b/src/main/java/org/sopt/domain/Article.java index 25126e0..a4bc2d0 100644 --- a/src/main/java/org/sopt/domain/Article.java +++ b/src/main/java/org/sopt/domain/Article.java @@ -5,9 +5,16 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; @Entity -@Table(name = "articles") +@Table(name = "articles", indexes = { + @Index(name = "idx_article_member_id", columnList = "memberId"), + @Index(name = "idx_article_created_date", columnList = "createdDate"), + @Index(name = "idx_article_category", columnList = "category"), + @Index(name = "idx_article_category_created", columnList = "category, createdDate") +}) @Getter @NoArgsConstructor public class Article { @@ -30,6 +37,10 @@ public class Article { private String content; + @OneToMany(mappedBy = "article", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private List comments = new ArrayList<>(); + + public Article(Member member, Category category, String title, String content) { this.member = member; this.category = category; diff --git a/src/main/java/org/sopt/domain/Comment.java b/src/main/java/org/sopt/domain/Comment.java new file mode 100644 index 0000000..2cf1e2f --- /dev/null +++ b/src/main/java/org/sopt/domain/Comment.java @@ -0,0 +1,46 @@ +package org.sopt.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "comments", indexes = { + @Index(name = "idx_comment_article_id", columnList = "articleId"), + @Index(name = "idx_comment_member_id", columnList = "memberId"), + @Index(name = "idx_comment_article_created", columnList = "articleId, createdDate") +}) +@Getter +@NoArgsConstructor +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch=FetchType.LAZY) + @JoinColumn(name = "memberId",nullable = false) + private Member member; + + @Column(nullable = false, length = 300) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="articleId",nullable = false) + private Article article; + + private LocalDate createdDate; + + public Comment(Member member, Article article, String content) { + this.member = member; + this.article = article; + this.content = content; + this.createdDate = LocalDate.now(); + } + + public void updateContent(String content) { + this.content = content; + } + +} diff --git a/src/main/java/org/sopt/dto/ArticleResponseDto.java b/src/main/java/org/sopt/dto/ArticleResponseDto.java index 8b10e84..eed9514 100644 --- a/src/main/java/org/sopt/dto/ArticleResponseDto.java +++ b/src/main/java/org/sopt/dto/ArticleResponseDto.java @@ -4,6 +4,8 @@ import org.sopt.domain.Category; import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; public record ArticleResponseDto( Long id, @@ -12,7 +14,8 @@ public record ArticleResponseDto( Category category, LocalDate createdDate, String title, - String content + String content, + List comments ) { public static ArticleResponseDto from(Article article) { return new ArticleResponseDto( @@ -22,7 +25,10 @@ public static ArticleResponseDto from(Article article) { article.getCategory(), article.getCreatedDate(), article.getTitle(), - article.getContent() + article.getContent(), + article.getComments().stream() + .map(CommentResponseDto::from) + .collect(Collectors.toList()) ); } } diff --git a/src/main/java/org/sopt/dto/CommentResponseDto.java b/src/main/java/org/sopt/dto/CommentResponseDto.java new file mode 100644 index 0000000..2f323e6 --- /dev/null +++ b/src/main/java/org/sopt/dto/CommentResponseDto.java @@ -0,0 +1,25 @@ +package org.sopt.dto; + +import org.sopt.domain.Comment; + +import java.time.LocalDate; + +public record CommentResponseDto( + Long id, + Long memberId, + String memberName, + Long articleId, + String content, + LocalDate createdDate +) { + public static CommentResponseDto from(Comment comment) { + return new CommentResponseDto( + comment.getId(), + comment.getMember().getId(), + comment.getMember().getName(), + comment.getArticle().getId(), + comment.getContent(), + comment.getCreatedDate() + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/dto/CreateCommentRequestDto.java b/src/main/java/org/sopt/dto/CreateCommentRequestDto.java new file mode 100644 index 0000000..cfe6ce7 --- /dev/null +++ b/src/main/java/org/sopt/dto/CreateCommentRequestDto.java @@ -0,0 +1,17 @@ +package org.sopt.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.hibernate.validator.constraints.NotBlank; + +public record CreateCommentRequestDto( + @NotNull(message = "회원 ID는 필수입니다.") + Long memberId, + + @NotNull(message = "게시글 ID는 필수입니다.") + Long articleId, + + @NotBlank(message = "댓글 내용은 필수입니다.") + @Size(max = 300, message = "댓글은 300자 이내로 작성해주세요.") + String content +) {} \ No newline at end of file diff --git a/src/main/java/org/sopt/dto/UpdateCommentRequestDto.java b/src/main/java/org/sopt/dto/UpdateCommentRequestDto.java new file mode 100644 index 0000000..2ffd49a --- /dev/null +++ b/src/main/java/org/sopt/dto/UpdateCommentRequestDto.java @@ -0,0 +1,10 @@ +package org.sopt.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateCommentRequestDto( + @NotBlank(message = "댓글 내용은 필수입니다.") + @Size(max = 300, message = "댓글은 300자 이내로 작성해주세요.") + String content +) {} \ No newline at end of file diff --git a/src/main/java/org/sopt/exception/CommentNotFoundException.java b/src/main/java/org/sopt/exception/CommentNotFoundException.java new file mode 100644 index 0000000..e243514 --- /dev/null +++ b/src/main/java/org/sopt/exception/CommentNotFoundException.java @@ -0,0 +1,7 @@ +package org.sopt.exception; + +public class CommentNotFoundException extends RuntimeException { + public CommentNotFoundException(Long commentId) { + super("댓글을 찾을 수 없습니다. ID: " + commentId); + } +} diff --git a/src/main/java/org/sopt/exception/UnauthorizedException.java b/src/main/java/org/sopt/exception/UnauthorizedException.java new file mode 100644 index 0000000..afcf371 --- /dev/null +++ b/src/main/java/org/sopt/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package org.sopt.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/global/GlobalExceptionHandler.java b/src/main/java/org/sopt/global/GlobalExceptionHandler.java index 875783f..37a92c7 100644 --- a/src/main/java/org/sopt/global/GlobalExceptionHandler.java +++ b/src/main/java/org/sopt/global/GlobalExceptionHandler.java @@ -23,6 +23,24 @@ public ResponseEntity> handleMemberNotFoundException( return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } + // 404: 댓글을 찾을 수 없음 + @ExceptionHandler(CommentNotFoundException.class) + public ResponseEntity> handleCommentNotFoundException( + CommentNotFoundException e) { + + ApiResponseDto response = ApiResponseDto.error(404, e.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + // 403: 댓글 수정 권한 없음 + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> handleUnauthorizedException( + UnauthorizedException e) { + + ApiResponseDto response = ApiResponseDto.error(403, e.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + // 409: 이메일 중복 @ExceptionHandler(DuplicateEmailException.class) public ResponseEntity> handleDuplicateEmailException( diff --git a/src/main/java/org/sopt/repository/ArticleRepository.java b/src/main/java/org/sopt/repository/ArticleRepository.java index 00da997..a45ac47 100644 --- a/src/main/java/org/sopt/repository/ArticleRepository.java +++ b/src/main/java/org/sopt/repository/ArticleRepository.java @@ -1,10 +1,31 @@ package org.sopt.repository; import org.sopt.domain.Article; +import org.sopt.domain.Category; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface ArticleRepository extends JpaRepository { boolean existsByTitle(String title); + // N+1 문제 해결: Member를 fetch join으로 한번에 조회 + @Query("SELECT a FROM Article a JOIN FETCH a.member ORDER BY a.createdDate DESC") + List
findAllWithMember(); + + // 카테고리별 조회 (인덱스 활용) + @Query("SELECT a FROM Article a JOIN FETCH a.member WHERE a.category = :category ORDER BY a.createdDate DESC") + List
findByCategoryWithMember(@Param("category") Category category); + + // 게시글 상세 조회 시 Member와 Comments를 한번에 조회 (N+1 해결) + @Query("SELECT DISTINCT a FROM Article a " + + "JOIN FETCH a.member " + + "LEFT JOIN FETCH a.comments c " + + "LEFT JOIN FETCH c.member " + + "WHERE a.id = :id") + Optional
findByIdWithMemberAndComments(@Param("id") Long id); } \ No newline at end of file diff --git a/src/main/java/org/sopt/repository/CommentRepository.java b/src/main/java/org/sopt/repository/CommentRepository.java new file mode 100644 index 0000000..2b1e8c8 --- /dev/null +++ b/src/main/java/org/sopt/repository/CommentRepository.java @@ -0,0 +1,21 @@ +package org.sopt.repository; + +import org.sopt.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + List findByArticleId(Long articleId); + + // 인덱스 사용, articleId로 조회 + @Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.article.id = :articleId ORDER BY c.createdDate DESC") + List findByArticleIdWithMember(@Param("articleId") Long articleId); + + // 댓글 단건 조회 시 Member도 함께 조회 + @Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.id = :id") + Optional findByIdWithMember(@Param("id") Long id); +} diff --git a/src/main/java/org/sopt/service/ArticleServiceImpl.java b/src/main/java/org/sopt/service/ArticleServiceImpl.java index b958db6..b85cacf 100644 --- a/src/main/java/org/sopt/service/ArticleServiceImpl.java +++ b/src/main/java/org/sopt/service/ArticleServiceImpl.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.sopt.domain.Article; +import org.sopt.domain.Category; import org.sopt.domain.Member; import org.sopt.dto.ArticleResponseDto; import org.sopt.dto.ArticleSummaryDto; @@ -11,6 +12,7 @@ import org.sopt.exception.MemberNotFoundException; import org.sopt.repository.ArticleRepository; import org.sopt.repository.MemberRepository; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,7 +55,7 @@ public Long createArticle(CreateArticleRequestDto req) { @Override public ArticleResponseDto getArticle(Long articleId) { - Article article = articleRepository.findById(articleId) + Article article = articleRepository.findByIdWithMemberAndComments(articleId) .orElseThrow(() -> new ArticleNotFoundException(articleId)); return ArticleResponseDto.from(article); @@ -61,7 +63,8 @@ public ArticleResponseDto getArticle(Long articleId) { @Override public List getAllArticles() { - return articleRepository.findAll().stream() + + return articleRepository.findAllWithMember().stream() .map(ArticleSummaryDto::from) .collect(Collectors.toList()); } diff --git a/src/main/java/org/sopt/service/CommentService.java b/src/main/java/org/sopt/service/CommentService.java new file mode 100644 index 0000000..0bf5e34 --- /dev/null +++ b/src/main/java/org/sopt/service/CommentService.java @@ -0,0 +1,14 @@ +package org.sopt.service; + +import org.sopt.dto.CommentResponseDto; +import org.sopt.dto.CreateCommentRequestDto; +import org.sopt.dto.UpdateCommentRequestDto; +import java.util.List; + +public interface CommentService { + Long createComment(CreateCommentRequestDto req); + CommentResponseDto getComment(Long commentId); + List getCommentsByArticle(Long articleId); + void updateComment(Long commentId, Long memberId, UpdateCommentRequestDto req); + void deleteComment(Long commentId, Long memberId); +} diff --git a/src/main/java/org/sopt/service/CommentServiceImpl.java b/src/main/java/org/sopt/service/CommentServiceImpl.java new file mode 100644 index 0000000..80026c7 --- /dev/null +++ b/src/main/java/org/sopt/service/CommentServiceImpl.java @@ -0,0 +1,97 @@ +package org.sopt.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.Article; +import org.sopt.domain.Comment; +import org.sopt.domain.Member; +import org.sopt.dto.CommentResponseDto; +import org.sopt.dto.CreateCommentRequestDto; +import org.sopt.dto.UpdateCommentRequestDto; +import org.sopt.exception.ArticleNotFoundException; +import org.sopt.exception.CommentNotFoundException; +import org.sopt.exception.MemberNotFoundException; +import org.sopt.exception.UnauthorizedException; +import org.sopt.repository.ArticleRepository; +import org.sopt.repository.CommentRepository; +import org.sopt.repository.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + private final ArticleRepository articleRepository; + + @Override + @Transactional + public Long createComment(CreateCommentRequestDto req) { + // 회원 확인 + Member member = memberRepository.findByIdAndIsDeletedFalse(req.memberId()) + .orElseThrow(() -> new MemberNotFoundException(req.memberId())); + + // 게시글 확인 + Article article = articleRepository.findById(req.articleId()) + .orElseThrow(() -> new ArticleNotFoundException(req.articleId())); + + // 댓글 생성 + Comment comment = new Comment(member, article, req.content()); + Comment savedComment = commentRepository.save(comment); + + return savedComment.getId(); + } + + @Override + public CommentResponseDto getComment(Long commentId) { + Comment comment = commentRepository.findByIdWithMember(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + return CommentResponseDto.from(comment); + } + + @Override + public List getCommentsByArticle(Long articleId) { + // 게시글 존재 확인 + if (!articleRepository.existsById(articleId)) { + throw new ArticleNotFoundException(articleId); + } + + return commentRepository.findByArticleId(articleId).stream() + .map(CommentResponseDto::from) + .collect(Collectors.toList()); + } + + @Override + @Transactional + public void updateComment(Long commentId, Long memberId, UpdateCommentRequestDto req) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + // 작성자 확인 + if (!comment.getMember().getId().equals(memberId)) { + throw new UnauthorizedException("댓글 수정 권한이 없습니다."); + } + + comment.updateContent(req.content()); + } + + @Override + @Transactional + public void deleteComment(Long commentId, Long memberId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + // 작성자 확인 + if (!comment.getMember().getId().equals(memberId)) { + throw new UnauthorizedException("댓글 삭제 권한이 없습니다."); + } + + commentRepository.delete(comment); + } +}