diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java index 4ceaa1a..c56a59d 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/controller/AnswerController.java @@ -58,16 +58,11 @@ public ResponseEntity selectAnswer( // 답변 삭제 (관리자만 가능) @DeleteMapping("/{answerId}") - @PreAuthorize("hasAnyRole('USER', 'ADMIN')") public ResponseEntity deleteAnswer( @PathVariable Long postId, @PathVariable Long answerId, @AuthenticationPrincipal CustomUserDetails userDetails) { - - Long userId = userDetails.getId(); - boolean isAdmin = "ROLE_ADMIN".equals(userDetails.getRole()); - - answerService.deleteAnswer(postId, answerId, userId, isAdmin); + answerService.deleteAnswer(postId, answerId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java index 500f66a..ddac75b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/entity/Answer.java @@ -1,39 +1,44 @@ package com.hyetaekon.hyetaekon.answer.entity; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import java.time.LocalDateTime; +import lombok.*; + @Entity @Getter @Setter +@Builder @NoArgsConstructor +@AllArgsConstructor @Table(name = "answer") -public class Answer { +public class Answer extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 답변 ID - @Column(name = "post_id", nullable = false) - private Long postId; // 게시글 ID + @ManyToOne + @JoinColumn(name = "post_id") + private Post post; - @Column(name = "user_id", nullable = false) - private Long userId; // 회원 ID + @ManyToOne + @JoinColumn(name = "user_id") + private User user; @Column(name = "content", columnDefinition = "TEXT", nullable = false) private String content; // 답변 내용 - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; // 생성일 - @Column(name = "selected", nullable = false) private boolean selected; // 채택 여부 - // ⭐ 생성 시점에 createdAt 자동 설정 - @PrePersist - protected void onCreate() { - this.createdAt = LocalDateTime.now(); + public String getDisplayContent() { + if (getDeletedAt() != null) { + return "삭제된 답변입니다."; + } else if (getSuspendAt() != null) { + return "관리자에 의해 삭제된 답변입니다."; + } + return content; } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java index 5b87c17..6ba8dc8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/mapper/AnswerMapper.java @@ -4,16 +4,12 @@ import com.hyetaekon.hyetaekon.answer.entity.Answer; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.factory.Mappers; +import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface AnswerMapper { - AnswerMapper INSTANCE = Mappers.getMapper(AnswerMapper.class); - + @Mapping(target = "content", expression = "java(answer.getDisplayContent())") AnswerDto toDto(Answer answer); - @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 - @Mapping(target = "selected", ignore = true) // 기본값 false 처리 - @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 Answer toEntity(AnswerDto answerDto); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java index ad0e284..caee8c8 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/repository/AnswerRepository.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.answer.repository; import com.hyetaekon.hyetaekon.answer.entity.Answer; +import com.hyetaekon.hyetaekon.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,9 +11,20 @@ @Repository public interface AnswerRepository extends JpaRepository { - // 페이지네이션 적용한 답변 목록 조회 - Page findByPostId(Long postId, Pageable pageable); + // 페이지네이션 적용한 답변 목록 조회 (post 객체 사용) + Page findByPost(Post post, Pageable pageable); // 채택 여부 및 등록일 기준 정렬된 페이징 처리된 답변 목록 조회 - Page findByPostIdOrderBySelectedDescCreatedAtDesc(Long postId, Pageable pageable); + Page findByPostOrderBySelectedDescCreatedAtDesc(Post post, Pageable pageable); + + // 삭제된 답변만 조회 (관리자용) + Page findByPostAndDeletedAtIsNotNull(Post post, Pageable pageable); + + // 정지된 답변만 조회 (관리자용) + Page findByPostAndSuspendAtIsNotNull(Post post, Pageable pageable); + + // 통계용 카운팅 메서드들 + long countByPost(Post post); + long countByPostAndDeletedAtIsNotNull(Post post); + long countByPostAndSuspendAtIsNotNull(Post post); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java index db98e22..1f39d63 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/answer/service/AnswerService.java @@ -9,6 +9,8 @@ import com.hyetaekon.hyetaekon.post.entity.Post; import com.hyetaekon.hyetaekon.post.repository.PostRepository; import com.hyetaekon.hyetaekon.user.entity.PointActionType; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import com.hyetaekon.hyetaekon.user.service.UserPointService; import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; @@ -28,29 +30,31 @@ public class AnswerService { private final AnswerRepository answerRepository; private final AnswerMapper answerMapper; private final PostRepository postRepository; + private final UserRepository userRepository; private final UserPointService userPointService; // 게시글에 답변 목록 조회 public Page getAnswersByPostId(Long postId, Pageable pageable) { // 게시글 존재 여부 확인 - postRepository.findByIdAndDeletedAtIsNull(postId) + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) .orElseThrow(() -> new GlobalException(ErrorCode.POST_NOT_FOUND_BY_ID)); // 채택된 답변이 먼저 나오고, 그 다음 최신순으로 정렬 - Page answersPage = answerRepository.findByPostIdOrderBySelectedDescCreatedAtDesc(postId, pageable); + Page answersPage = answerRepository.findByPostOrderBySelectedDescCreatedAtDesc(post, pageable); return answersPage.map(answerMapper::toDto); } public AnswerDto createAnswer(Long postId, AnswerDto answerDto, Long userId) { - // 게시글 존재 여부 확인 - postRepository.findByIdAndDeletedAtIsNull(postId) + // 게시글과 사용자 객체 조회 + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) .orElseThrow(() -> new GlobalException(ErrorCode.POST_NOT_FOUND_BY_ID)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); Answer answer = answerMapper.toEntity(answerDto); - answer.setPostId(postId); - answer.setUserId(userId); - answer.setCreatedAt(LocalDateTime.now()); + answer.setPost(post); + answer.setUser(user); answer = answerRepository.save(answer); userPointService.addPointForAction(userId, PointActionType.ANSWER_CREATION); @@ -73,7 +77,7 @@ public void selectAnswer(Long postId, Long answerId, Long userId) { .orElseThrow(() -> new GlobalException(ErrorCode.ANSWER_NOT_FOUND)); // 답변이 해당 게시글에 속하는지 확인 - if (!answer.getPostId().equals(postId)) { + if (!answer.getPost().getId().equals(postId)) { // post 객체 사용 throw new GlobalException(ErrorCode.ANSWER_NOT_MATCHED_POST); } @@ -82,23 +86,28 @@ public void selectAnswer(Long postId, Long answerId, Long userId) { answerRepository.save(answer); // 답변 작성자에게 포인트 부여 - userPointService.addPointForAction(answer.getUserId(), PointActionType.ANSWER_ACCEPTED); + userPointService.addPointForAction(answer.getUser().getId(), PointActionType.ANSWER_ACCEPTED); // user 객체 사용 } @Transactional - public void deleteAnswer(Long postId, Long answerId, Long userId, boolean isAdmin) { + public void deleteAnswer(Long postId, Long answerId, Long userId, String role) { Answer answer = answerRepository.findById(answerId) - .orElseThrow(() -> new GlobalException(ErrorCode.ANSWER_NOT_FOUND)); + .orElseThrow(() -> new GlobalException(ErrorCode.ANSWER_NOT_FOUND)); - if (!answer.getPostId().equals(postId)) { + if (!answer.getPost().getId().equals(postId)) { // post 객체 사용 throw new GlobalException(ErrorCode.ANSWER_NOT_MATCHED_POST); } - if (!answer.getUserId().equals(userId) && !isAdmin) { - throw new AccessDeniedException("답변 삭제 권한이 없습니다."); + // 작성자 또는 관리자 확인 + boolean isOwner = answer.getUser().getId().equals(userId); // user 객체 사용 + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("답변 삭제 권한이 없습니다"); } - answerRepository.delete(answer); + answer.delete(); // soft delete 사용 + answerRepository.save(answer); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java index 81278eb..7be3783 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/controller/CommentController.java @@ -1,6 +1,8 @@ package com.hyetaekon.hyetaekon.comment.controller; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.service.CommentService; import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails; import lombok.RequiredArgsConstructor; @@ -20,54 +22,58 @@ public class CommentController { // 게시글 댓글 목록 조회 (페이징 지원) @GetMapping - @PreAuthorize("hasRole('USER')") - public ResponseEntity> getComments(@PathVariable Long postId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - Page comments = commentService.getComments(postId, page, size); + public ResponseEntity> getComments( + @PathVariable("postId") Long postId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + Page comments = commentService.getComments(postId, page, size); return ResponseEntity.ok(comments); } // 게시글에 댓글 작성 @PostMapping - @PreAuthorize("hasRole('USER')") - public ResponseEntity createComment(@PathVariable Long postId, @RequestBody CommentDto commentDto) { - CommentDto createdComment = commentService.createComment(postId, commentDto); + public ResponseEntity createComment( + @PathVariable("postId") Long postId, + @RequestBody CommentCreateRequestDto commentDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + CommentListResponseDto createdComment = commentService.createComment(postId, userDetails.getId(), commentDto); return ResponseEntity.status(HttpStatus.CREATED).body(createdComment); } // 대댓글 목록 조회 @GetMapping("/{commentId}/replies") - @PreAuthorize("hasRole('USER')") - public ResponseEntity> getReplies(@PathVariable Long postId, - @PathVariable Long commentId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "5") int size) { - Page replies = commentService.getReplies(postId, commentId, page, size); + public ResponseEntity> getReplies( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size) { + Page replies = commentService.getReplies(postId, commentId, page, size); return ResponseEntity.ok(replies); } - // 대댓글 작성 + //대댓글 작성 @PostMapping("/{commentId}/replies") - @PreAuthorize("hasRole('USER')") - public ResponseEntity createReply(@PathVariable Long postId, - @PathVariable Long commentId, - @RequestBody CommentDto commentDto) { - CommentDto createdReply = commentService.createReply(postId, commentId, commentDto); + public ResponseEntity createReply( + @PathVariable("postId") Long postId, + @PathVariable("commentId") Long commentId, + @RequestBody CommentCreateRequestDto commentDto, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + // 대댓글의 부모 댓글 ID 설정 + commentDto.setParentId(commentId); + + CommentListResponseDto createdReply = commentService.createComment(postId, userDetails.getId(), commentDto); return ResponseEntity.status(HttpStatus.CREATED).body(createdReply); } - // 댓글 삭제 (관리자만 가능) - @DeleteMapping("/admin/comments/{commentId}") - @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + // 댓글 삭제 (관리자나 댓글 작성자만 가능) + @DeleteMapping("/{commentId}") public ResponseEntity deleteComment( - @PathVariable Long commentId, - @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable String postId) { - - Long currentUserId = userDetails.getId(); - boolean isAdmin = "ROLE_ADMIN".equals(userDetails.getRole()); + @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { - commentService.deleteComment(commentId, currentUserId, isAdmin); + commentService.deleteComment(commentId, userDetails.getId(), userDetails.getRole()); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java new file mode 100644 index 0000000..f6661ab --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentCreateRequestDto.java @@ -0,0 +1,15 @@ +package com.hyetaekon.hyetaekon.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentCreateRequestDto { + private Long parentId; + private String content; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java new file mode 100644 index 0000000..69b5e0e --- /dev/null +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/dto/CommentListResponseDto.java @@ -0,0 +1,19 @@ +package com.hyetaekon.hyetaekon.comment.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentListResponseDto { + private Long id; + private Long postId; + private Long parentId; // 대댓글일 경우 부모 댓글 ID + private String content; + private String nickname; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java index 16e0fb0..f33a837 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/entity/Comment.java @@ -1,46 +1,45 @@ package com.hyetaekon.hyetaekon.comment.entity; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.user.entity.User; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder @NoArgsConstructor @AllArgsConstructor @Table(name = "comments") -public class Comment { +public class Comment extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private Long postId; + @ManyToOne + @JoinColumn(name = "post_id") + private Post post; - @Column(name = "user_id", nullable = false) - private Long userId; - - @Column(name = "nickname", nullable = false) - private String nickname; + @ManyToOne + @JoinColumn(name = "user_id") + private User user; private Long parentId; // 대댓글이면 부모 댓글 ID, 아니면 null @Column(nullable = false, length = 1000) private String content; - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(nullable = false) - private boolean deleted = false; - - // ⭐ 생성 시점에 createdAt 자동 설정 - @PrePersist - protected void onCreate() { - this.createdAt = LocalDateTime.now(); + public String getDisplayContent() { + if (getDeletedAt() != null) { + return "삭제된 댓글입니다."; + } else if (getSuspendAt() != null) { + return "관리자에 의해 삭제된 댓글입니다."; + } + return content; } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java index 61fa050..beee62e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/mapper/CommentMapper.java @@ -1,18 +1,22 @@ package com.hyetaekon.hyetaekon.comment.mapper; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.entity.Comment; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; import org.mapstruct.factory.Mappers; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface CommentMapper { - CommentMapper INSTANCE = Mappers.getMapper(CommentMapper.class); - CommentDto toDto(Comment comment); + Comment toEntity(CommentCreateRequestDto requestDto); - @Mapping(target = "id", ignore = true) // id는 자동 생성되므로 무시 - @Mapping(target = "createdAt", ignore = true) // createdAt 자동 설정 - Comment toEntity(CommentDto commentDto); -} + @Mapping(source = "user.nickname", target = "nickname") + @Mapping(source = "post.id", target = "postId") + @Mapping(target = "content", expression = "java(comment.getDisplayContent())") + CommentListResponseDto toResponseDto(Comment comment); + +} \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java index f31d589..6c40166 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/repository/CommentRepository.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.comment.repository; import com.hyetaekon.hyetaekon.comment.entity.Comment; +import com.hyetaekon.hyetaekon.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,6 +9,20 @@ @Repository public interface CommentRepository extends JpaRepository { - Page findByPostId(Long postId, Pageable pageable); - Page findByPostIdAndParentId(Long postId, Long parentId, Pageable pageable); + // 게시글의 최상위 댓글 조회 (삭제되지 않은 댓글만) + Page findByPostAndParentIdIsNull(Post post, Pageable pageable); + + // 특정 댓글의 대댓글 조회 (삭제되지 않은 댓글만) + Page findByPostAndParentId(Post post, Long parentId, Pageable pageable); + + // 삭제된 댓글만 조회 (관리자용) + Page findByPostAndDeletedAtIsNotNull(Post post, Pageable pageable); + + // 정지된 댓글만 조회 (관리자용) + Page findByPostAndSuspendAtIsNotNull(Post post, Pageable pageable); + + // 통계용 카운팅 메서드들 + long countByPost(Post post); + long countByPostAndDeletedAtIsNotNull(Post post); + long countByPostAndSuspendAtIsNotNull(Post post); } diff --git a/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java index 600cc47..303625e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/comment/service/CommentService.java @@ -1,58 +1,106 @@ package com.hyetaekon.hyetaekon.comment.service; -import com.hyetaekon.hyetaekon.comment.dto.CommentDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentCreateRequestDto; +import com.hyetaekon.hyetaekon.comment.dto.CommentListResponseDto; import com.hyetaekon.hyetaekon.comment.entity.Comment; import com.hyetaekon.hyetaekon.comment.mapper.CommentMapper; import com.hyetaekon.hyetaekon.comment.repository.CommentRepository; +import com.hyetaekon.hyetaekon.post.entity.Post; +import com.hyetaekon.hyetaekon.post.repository.PostRepository; +import com.hyetaekon.hyetaekon.user.entity.User; +import com.hyetaekon.hyetaekon.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class CommentService { private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; private final CommentMapper commentMapper; - public Page getComments(Long postId, int page, int size) { + /** + * 게시글의 댓글 목록 조회 + */ + @Transactional(readOnly = true) + public Page getComments(Long postId, int page, int size) { Pageable pageable = PageRequest.of(page, size); - return commentRepository.findByPostId(postId, pageable) - .map(commentMapper::toDto); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + return commentRepository.findByPostAndParentIdIsNull(post, pageable) + .map(commentMapper::toResponseDto); } - public CommentDto createComment(Long postId, CommentDto commentDto) { - Comment comment = commentMapper.toEntity(commentDto); - comment.setPostId(postId); + /** + * 댓글 생성 + */ + @Transactional + public CommentListResponseDto createComment(Long postId, Long userId, CommentCreateRequestDto requestDto) { + // 사용자와 게시글 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다")); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + // 엔티티 생성 및 설정 + Comment comment = commentMapper.toEntity(requestDto); + comment.setUser(user); + comment.setPost(post); + + // 저장 및 DTO 변환 comment = commentRepository.save(comment); - return commentMapper.toDto(comment); + return commentMapper.toResponseDto(comment); } - public Page getReplies(Long postId, Long commentId, int page, int size) { + /** + * 대댓글 목록 조회 + */ + @Transactional(readOnly = true) + public Page getReplies(Long postId, Long commentId, int page, int size) { Pageable pageable = PageRequest.of(page, size); - return commentRepository.findByPostIdAndParentId(postId, commentId, pageable) - .map(commentMapper::toDto); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다")); + + return commentRepository.findByPostAndParentId(post, commentId, pageable) + .map(commentMapper::toResponseDto); + } + + /** + * 댓글 삭제 (본인 또는 관리자만 가능) + */ + @Transactional + public void deleteComment(Long commentId, Long userId, String role) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new RuntimeException("댓글을 찾을 수 없습니다")); + + // 작성자 또는 관리자 확인 + boolean isOwner = comment.getUser().getId().equals(userId); + boolean isAdmin = "ROLE_ADMIN".equals(role); + + if (!isOwner && !isAdmin) { + throw new AccessDeniedException("댓글 삭제 권한이 없습니다"); + } + + // Soft Delete 처리 + comment.delete(); + commentRepository.save(comment); } - public CommentDto createReply(Long postId, Long commentId, CommentDto commentDto) { + /*public CommentDto createReply(Long postId, Long commentId, CommentDto commentDto) { Comment reply = commentMapper.toEntity(commentDto); reply.setPostId(postId); reply.setParentId(commentId); reply = commentRepository.save(reply); return commentMapper.toDto(reply); - } + }*/ - public void deleteComment(Long commentId, Long currentUserId, boolean isAdmin) { - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new RuntimeException("댓글 없음")); - - if (comment.getUserId().equals(currentUserId) || isAdmin) { - comment.setDeleted(true); - comment.setContent("삭제된 댓글입니다."); - commentRepository.save(comment); - } else { - throw new RuntimeException("삭제 권한 없음"); - } - } } diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java index 4ee0c58..3b77429 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/config/SecurityPath.java @@ -35,13 +35,16 @@ public class SecurityPath { "/api/mongo/services/matched", "/api/users/reports", "/api/posts/*/answers", - "/api/posts/*/answers/*/select" + "/api/posts/*/answers/*", + "/api/posts/*/answers/*/select", + "/api/posts/*/comments", + "/api/posts/*/comments/*", + "/api/posts/*/comments/*/replies" }; // hasRole("ADMIN") public static final String[] ADMIN_ENDPOINTS = { "/api/admin/**", - "/api/posts/*/answers/admin/answers/*", "/api/public-data/serviceDetailList", "/api/public-data/supportConditionsList", "/api/public-data/serviceList" diff --git a/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java index 10e924c..06a0b25 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java +++ b/src/main/java/com/hyetaekon/hyetaekon/common/util/BaseEntity.java @@ -4,14 +4,15 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; +import lombok.Setter; import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @EntityListeners(AuditingEntityListener.class) @Getter +@Setter @MappedSuperclass public class BaseEntity { @@ -19,7 +20,25 @@ public class BaseEntity { @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") private LocalDateTime createdAt; - @LastModifiedDate - @Column(name = "modified_at", nullable = false, columnDefinition = "DATETIME(0)") - private LocalDateTime modifiedAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "suspend_at") + private LocalDateTime suspendAt; + + // 삭제 처리 + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + // 정지 처리 + public void suspend() { + this.suspendAt = LocalDateTime.now(); + } + + // 복원시 + public void restore() { + this.deletedAt = null; + this.suspendAt = null; + } } \ No newline at end of file diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java index c574aac..ad653d6 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostCreateRequestDto.java @@ -12,10 +12,8 @@ @NoArgsConstructor @AllArgsConstructor public class PostCreateRequestDto { - private String nickName; private String title; private String content; - private LocalDateTime createdAt; private String postType; private String urlTitle; private String urlPath; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java index 600b6a2..80a8970 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/dto/PostUpdateRequestDto.java @@ -12,10 +12,8 @@ @NoArgsConstructor @AllArgsConstructor public class PostUpdateRequestDto { - private String nickName; private String title; private String content; - // private LocalDateTime createdAt; private String postType; private String urlTitle; private String urlPath; diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java index c64df29..7110312 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/Post.java @@ -1,6 +1,7 @@ package com.hyetaekon.hyetaekon.post.entity; import com.hyetaekon.hyetaekon.bookmark.entity.Bookmark; +import com.hyetaekon.hyetaekon.common.util.BaseEntity; import com.hyetaekon.hyetaekon.publicservice.entity.PublicService; import com.hyetaekon.hyetaekon.recommend.entity.Recommend; import com.hyetaekon.hyetaekon.user.entity.User; @@ -18,7 +19,7 @@ @NoArgsConstructor @AllArgsConstructor @Table(name = "post") -public class Post { +public class Post extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -38,11 +39,6 @@ public class Post { @Column(columnDefinition = "VARCHAR(500) CHARACTER SET utf8mb4", nullable = false) // ✅ 내용 500자 제한 private String content; - @Builder.Default - private LocalDateTime createdAt = null; // 빌더 사용 시 null로 초기화 - - private LocalDateTime deletedAt; - @Builder.Default @Column(name = "recommend_cnt") private int recommendCnt = 0; // 추천수 @@ -73,6 +69,7 @@ public class Post { @Column(name = "category_id") private Long categoryId; + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List postImages = new ArrayList<>(); // ✅ 게시글 이미지와 연결 @@ -81,14 +78,6 @@ public class Post { @Builder.Default private List recommends = new ArrayList<>(); - // 저장 시점에 현재 시간으로 설정 - @PrePersist - public void prePersist() { - if (this.createdAt == null) { - this.createdAt = LocalDateTime.now(); - } - } - // 조회수 증가 public void incrementViewCnt() { this.viewCnt++; @@ -113,4 +102,13 @@ public void decrementCommentCnt() { } + public String getDisplayContent() { + if (getDeletedAt() != null) { + return "삭제된 게시글입니다."; + } else if (getSuspendAt() != null) { + return "관리자에 의해 삭제된 게시글입니다."; + } + return content; + } + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java index 7325e94..ffc5702 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/entity/PostImage.java @@ -26,11 +26,6 @@ public class PostImage { private LocalDateTime deletedAt; - // 이미지가 삭제되었는지 확인하는 메소드 - public boolean isDeleted() { - return deletedAt != null; - } - // Soft delete 처리 메소드 public void softDelete() { this.deletedAt = LocalDateTime.now(); diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java index 8493601..68c8308 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/mapper/PostMapper.java @@ -23,16 +23,14 @@ public interface PostMapper { // ✅ 마이페이지용 게시글 DTO @Mapping(source = "id", target = "postId") @Mapping(source = "user.nickname", target = "nickName") + @Mapping(target = "content", expression = "java(post.getDisplayContent())") MyPostListResponseDto toMyPostListDto(Post post); // ✅ 게시글 생성 시 DTO → Entity 변환 - @Mapping(target = "createdAt", expression = "java(java.time.LocalDateTime.now())") - @Mapping(target = "postType", ignore = true) // postType은 Service에서 직접 세팅 Post toEntity(PostCreateRequestDto createDto); // ✅ 게시글 수정 시 일부 값만 업데이트 (null 무시) @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) - @Mapping(target = "postType", ignore = true) void updatePostFromDto(PostUpdateRequestDto updateDto, @MappingTarget Post post); // ✅ 게시글 상세 보기용 DTO (imageUrls 수동으로 처리) @@ -41,7 +39,7 @@ default PostDetailResponseDto toPostDetailDto(Post post) { .postId(post.getId()) .nickName(post.getUser().getNickname()) .title(post.getTitle()) - .content(post.getContent()) + .content(post.getDisplayContent()) .createdAt(post.getCreatedAt()) .postType(post.getPostType().getKoreanName()) .recommendCnt(post.getRecommendCnt()) diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java index 502b593..604a73e 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/repository/PostRepository.java @@ -35,4 +35,16 @@ public interface PostRepository extends JpaRepository { // 제목 검색 + 특정 타입 + 삭제되지 않은 게시글 Page findByPostTypeAndTitleContainingAndDeletedAtIsNull(PostType postType, String keyword, Pageable pageable); + // 삭제된 게시글만 조회 (관리자용) + Page findByDeletedAtIsNotNull(Pageable pageable); + + // 정지된 게시글만 조회 (관리자용) + Page findBySuspendAtIsNotNull(Pageable pageable); + + + // 통계용 카운팅 메서드들 + long count(); + long countByDeletedAtIsNotNull(); + long countBySuspendAtIsNotNull(); + } diff --git a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java index a0ea63a..f6dc0c1 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java +++ b/src/main/java/com/hyetaekon/hyetaekon/post/service/PostService.java @@ -181,6 +181,7 @@ public PostDetailResponseDto updatePost(Long postId, PostUpdateRequestDto update for (PostImage image : existingImages) { image.softDelete(); } + postImageRepository.saveAll(existingImages); // 새 이미지 추가 List newImages = processPostImages(updateDto.getImages(), post); @@ -209,13 +210,16 @@ public void deletePost(Long postId, Long userId, String role) { } // Soft Delete 처리 - post.setDeletedAt(LocalDateTime.now()); + post.delete(); // 모든 이미지 soft delete 처리 List images = postImageRepository.findByPostAndDeletedAtIsNull(post); for (PostImage image : images) { image.softDelete(); } + + postRepository.save(post); + postImageRepository.saveAll(images); } /** diff --git a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java index f29ba0a..346d84b 100644 --- a/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java +++ b/src/main/java/com/hyetaekon/hyetaekon/publicservice/repository/mongodb/MatchedServiceClient.java @@ -203,44 +203,16 @@ private void addUserMatchBoosts( // 나이 조건 (should로 변경) if (userAge != null) { - int age = userAge; - - // 대상 나이 범위가 null이거나 사용자 나이를 포함하는 서비스에 가산점 - clauses.add(""" - { - compound: { - should: [ - { - compound: { - mustNot: [{exists: {path: "targetAgeStart"}}] - } - }, - { - range: {path: "targetAgeStart", lte: %d} - } - ], - score: {boost: {value: 4.5}} - } - } - """.formatted(age)); - clauses.add(""" { - compound: { - should: [ - { - compound: { - mustNot: [{exists: {path: "targetAgeEnd"}}] - } - }, - { - range: {path: "targetAgeEnd", gte: %d} - } - ], - score: {boost: {value: 4.5}} + range: { + path: ["targetAgeStart", "targetAgeEnd"], + gte: 0, + lte: %d, + score: { boost: { value: 4.5 } } } } - """.formatted(age)); + """.formatted(userAge)); } // 직업 관련 조건 추가