diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java new file mode 100644 index 00000000..8e09fcff --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/PostController.java @@ -0,0 +1,137 @@ +package org.sejongisc.backend.board.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.board.entity.BoardType; +import org.sejongisc.backend.board.entity.PostType; +import org.sejongisc.backend.board.dto.*; +import org.sejongisc.backend.board.service.PostService; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/post") +@Tag( + name = "게시글 및 댓글 API", + description = "게시글 및 댓글 작성, 수정, 삭제 관련 API 제공" +) +public class PostController { + + private final PostService postService; + + // 게시글 작성 + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createPost( + @Valid @ModelAttribute PostRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.savePost(request, userId); + return ResponseEntity.ok().build(); + } + + // 게시글 수정 + @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updatePost( + @Valid @ModelAttribute PostRequest request, + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.updatePost(request, postId, userId); + return ResponseEntity.ok().build(); + } + + // 게시글 삭제 + @DeleteMapping("/{postId}") + public void deletePost( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.deletePost(postId, userId); + } + + // 게시글 조회 (공지/일반) + @GetMapping + public ResponseEntity> getPosts( + @RequestParam BoardType boardType, + @RequestParam(defaultValue = "0") int pageNumber, + @RequestParam(defaultValue = "20") int pageSize) { + return ResponseEntity.ok(postService.getPosts(boardType, pageNumber, pageSize)); + } + + // 게시글 검색 + @GetMapping("/search") + public ResponseEntity> searchPosts( + @RequestParam String keyword, + @RequestParam(defaultValue = "0") int pageNumber, + @RequestParam(defaultValue = "20") int pageSize) { + return ResponseEntity.ok(postService.searchPosts(keyword, pageNumber, pageSize)); + } + + // 게시물 상세 조회 + @GetMapping("/{postId}") + public ResponseEntity getPostDetail( + @PathVariable UUID postId, + @RequestParam(defaultValue = "0") int commentPageNumber, + @RequestParam(defaultValue = "20") int commentPageSize) { + PostResponse response = postService.getPostDetail(postId, commentPageNumber, commentPageSize); + return ResponseEntity.ok(response); + } + + // 좋아요 토글 + @PostMapping("/{postId}/like") + public ResponseEntity toggleLike( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.toggleLike(postId, userId); + return ResponseEntity.ok().build(); + } + + // 북마크 토글 + @PostMapping("/{postId}/bookmark") + public ResponseEntity toggleBookmark( + @PathVariable UUID postId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.toggleBookmark(postId, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 작성 + @PostMapping("/{postId}/comment") + public ResponseEntity createComment( + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.createComment(request, userId); + return ResponseEntity.ok().build(); + } + + // 댓글 수정 + @PutMapping("/comment/{commentId}") + public void updateComment( + @PathVariable UUID commentId, + @RequestBody CommentRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.updateComment(request, commentId, userId); + } + + // 댓글 삭제 + @DeleteMapping("/comment/{commentId}") + public void deleteComment( + @PathVariable UUID commentId, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + UUID userId = customUserDetails.getUserId(); + postService.deleteComment(commentId, userId); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java new file mode 100644 index 00000000..d6a5099c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentRequest.java @@ -0,0 +1,26 @@ +package org.sejongisc.backend.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class CommentRequest { + + @NotNull + private UUID postId; + + @NotBlank(message = "댓글 내용은 필수 항목입니다.") + private String content; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java new file mode 100644 index 00000000..7bcf5c89 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/CommentResponse.java @@ -0,0 +1,36 @@ +package org.sejongisc.backend.board.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.sejongisc.backend.board.entity.Comment; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class CommentResponse { + private UUID commentId; + private UUID postId; + private String content; + private LocalDateTime createdDate; + private LocalDateTime updatedDate; + + public static CommentResponse of(Comment comment) { + return CommentResponse.builder() + .commentId(comment.getCommentId()) + .postId(comment.getPost().getPostId()) + .content(comment.getContent()) + .createdDate(comment.getCreatedDate()) + .updatedDate(comment.getUpdatedDate()) + .build(); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostAttachmentResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostAttachmentResponse.java new file mode 100644 index 00000000..7030989e --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostAttachmentResponse.java @@ -0,0 +1,26 @@ +package org.sejongisc.backend.board.dto; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sejongisc.backend.board.entity.PostAttachment; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostAttachmentResponse { + private UUID postAttachmentId; + private String originalFilename; + private String filePath; + + public static PostAttachmentResponse of(PostAttachment attachment) { + return PostAttachmentResponse.builder() + .postAttachmentId(attachment.getPostAttachmentId()) + .originalFilename(attachment.getOriginalFilename()) + .filePath(attachment.getFilePath()) + .build(); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java new file mode 100644 index 00000000..37c7663a --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostRequest.java @@ -0,0 +1,37 @@ +package org.sejongisc.backend.board.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.sejongisc.backend.board.entity.BoardType; +import org.sejongisc.backend.board.entity.PostType; +import org.springframework.web.multipart.MultipartFile; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class PostRequest { + + @NotNull(message = "게시판 타입을 선택해주세요.") + private BoardType boardType; + + @NotBlank(message = "제목은 필수 항목입니다.") + private String title; + + @NotBlank(message = "내용은 필수 항목입니다.") + private String content; + + @NotNull(message = "게시글 타입을 선택해주세요.") + private PostType postType; + + private List files; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java new file mode 100644 index 00000000..fad77c17 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostResponse.java @@ -0,0 +1,40 @@ +package org.sejongisc.backend.board.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.sejongisc.backend.board.entity.BoardType; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostType; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.sejongisc.backend.user.entity.User; +import org.springframework.data.domain.Page; + +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +public class PostResponse { + + private UUID postId; + private BoardType boardType; + private User user; + private String title; + private String content; + private PostType postType; + private Integer bookmarkCount; + private Integer likeCount; + private Integer commentCount; + private LocalDateTime createdDate; + private LocalDateTime updatedDate; + private Page comments; + private List attachments; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/dto/PostSummaryResponse.java b/backend/src/main/java/org/sejongisc/backend/board/dto/PostSummaryResponse.java new file mode 100644 index 00000000..0e7638c6 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/dto/PostSummaryResponse.java @@ -0,0 +1,17 @@ +package org.sejongisc.backend.board.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@AllArgsConstructor +public class PostSummaryResponse { + private UUID id; + private String title; + private int likeCount; + private int commentCount; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java b/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java new file mode 100644 index 00000000..722f6d8a --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/BoardType.java @@ -0,0 +1,7 @@ +package org.sejongisc.backend.board.entity; + +public enum BoardType { + GENERAL, // 전체 게시판 + FINANCE_IT, // 금융 IT 게시판 + ASSET_MANAGEMENT // 자산 운용 게시판 +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java new file mode 100644 index 00000000..7264740c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Comment.java @@ -0,0 +1,31 @@ +package org.sejongisc.backend.board.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; +import java.util.UUID; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Comment extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID commentId; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java new file mode 100644 index 00000000..26254d61 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/Post.java @@ -0,0 +1,60 @@ +package org.sejongisc.backend.board.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.GenericGenerator; + +import java.util.UUID; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +public class Post extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(nullable = false, updatable = false) + private UUID postId; + + // 작성자 + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + // 게시판 타입 + @Enumerated(EnumType.STRING) + private BoardType boardType; + + // 제목 + @Column(nullable = false) + private String title; + + // 내용 + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + // 게시글 타입 + @Enumerated(EnumType.STRING) + private PostType postType; + + // 북마크 수 + @Builder.Default + private Integer bookmarkCount = 0; + + // 좋아요 수 + @Builder.Default + private Integer likeCount = 0; + + // 댓글 수 + @Builder.Default + private Integer commentCount = 0; + + @Version + private Long version; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java new file mode 100644 index 00000000..30b534a1 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostAttachment.java @@ -0,0 +1,34 @@ +package org.sejongisc.backend.board.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; +import java.util.UUID; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PostAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID postAttachmentId; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @Column(nullable = false) + private String savedFilename; + + @Column(nullable = false) + private String originalFilename; + + @Column(nullable = false) + private String filePath; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java new file mode 100644 index 00000000..f0fea243 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostBookmark.java @@ -0,0 +1,28 @@ +package org.sejongisc.backend.board.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; +import java.util.UUID; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PostBookmark extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID postBookmarkId; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java new file mode 100644 index 00000000..762d7e0f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostLike.java @@ -0,0 +1,28 @@ +package org.sejongisc.backend.board.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; +import java.util.UUID; +import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; +import org.sejongisc.backend.user.entity.User; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PostLike extends BasePostgresEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID postLikeId; + + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java b/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java new file mode 100644 index 00000000..fc8f4640 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/entity/PostType.java @@ -0,0 +1,6 @@ +package org.sejongisc.backend.board.entity; + +public enum PostType { + NORMAL, // 일반 + NOTICE // 공지 +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java new file mode 100644 index 00000000..458e586c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/CommentRepository.java @@ -0,0 +1,22 @@ +package org.sejongisc.backend.board.repository; + +import org.sejongisc.backend.board.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; +import org.springframework.transaction.annotation.Transactional; + +public interface CommentRepository extends JpaRepository { + + List findByPostPostId(UUID postId); + + List findAllByPostPostId(UUID postId); + + Page findAllByPostPostId(UUID postId, Pageable pageable); + + @Transactional + void deleteAllByPostPostId(UUID postId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostAttachmentRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostAttachmentRepository.java new file mode 100644 index 00000000..ff5c8695 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostAttachmentRepository.java @@ -0,0 +1,14 @@ +package org.sejongisc.backend.board.repository; + +import org.sejongisc.backend.board.entity.PostAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface PostAttachmentRepository extends JpaRepository { + + List findAllByPostPostId(UUID postId); + + void deleteAllByPostPostId(UUID postId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostBookmarkRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostBookmarkRepository.java new file mode 100644 index 00000000..94e6f9d7 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostBookmarkRepository.java @@ -0,0 +1,21 @@ +package org.sejongisc.backend.board.repository; + +import java.util.List; +import org.sejongisc.backend.board.entity.PostBookmark; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.transaction.annotation.Transactional; + +public interface PostBookmarkRepository extends JpaRepository { + + boolean existsByUserUserIdAndPostPostId(UUID userId, UUID postId); + + Optional findByPostPostIdAndUserUserId(UUID postId, UUID userId); + + List findAllByPostPostId(UUID postId); + + @Transactional + void deleteAllByPostPostId(UUID postId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostLikeRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostLikeRepository.java new file mode 100644 index 00000000..0df84ee4 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostLikeRepository.java @@ -0,0 +1,21 @@ +package org.sejongisc.backend.board.repository; + +import java.util.List; +import org.sejongisc.backend.board.entity.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.transaction.annotation.Transactional; + +public interface PostLikeRepository extends JpaRepository { + + boolean existsByUserUserIdAndPostPostId(UUID userId, UUID postId); + + Optional findByPostPostIdAndUserUserId(UUID postId, UUID userId); + + List findAllByPostPostId(UUID postId); + + @Transactional + void deleteAllByPostPostId(UUID postId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java new file mode 100644 index 00000000..7ab8316a --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/repository/PostRepository.java @@ -0,0 +1,17 @@ +package org.sejongisc.backend.board.repository; + +import org.sejongisc.backend.board.entity.BoardType; +import org.sejongisc.backend.board.entity.Post; +import org.sejongisc.backend.board.entity.PostType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface PostRepository extends JpaRepository { + Page findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + String titleKeyword, String contentKeyword, Pageable pageable); + Page findAllByBoardType(BoardType boardType, Pageable pageable); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/FileUploadService.java b/backend/src/main/java/org/sejongisc/backend/board/service/FileUploadService.java new file mode 100644 index 00000000..08b55b02 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/service/FileUploadService.java @@ -0,0 +1,92 @@ +package org.sejongisc.backend.board.service; + + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class FileUploadService { + +// @Value("${file.upload-dir}") + private final static String UPLOAD_DIRS = "C:/uploads/"; + + private Path rootLocation; + + // 서비스 생성 시 업로드 디렉토리가 없으면 생성 + @PostConstruct + public void init() { + try { + this.rootLocation = Paths.get(UPLOAD_DIRS).toAbsolutePath().normalize(); + Files.createDirectories(this.rootLocation); + } catch (IOException e) { + throw new RuntimeException("업로드할 디렉토리를 생성할 수 없습니다.", e); + } + } + + /** + * 파일 저장 + * @param file 업로드된 파일 + * @return 저장된 파일명 (UUID 포함) + */ + public String store(MultipartFile file) { + if (file.isEmpty()) { + throw new RuntimeException("빈 파일은 저장할 수 없습니다."); + } + + // 원본 파일명 정리 (경로 조작 방지) + String originalFilename = StringUtils.cleanPath(file.getOriginalFilename()); + + try { + // 파일명 중복 방지를 위해 UUID 추가 + String savedFilename = UUID.randomUUID().toString() + "_" + originalFilename; + + // 저장할 경로 생성 + Path destinationFile = this.rootLocation.resolve(savedFilename).normalize(); + + // 상위 디렉토리로 벗어나려는지 보안 체크 + if (!destinationFile.getParent().equals(this.rootLocation)) { + throw new RuntimeException("현재 디렉토리 밖에 저장할 수 없습니다."); + } + + // 파일 복사 (이미 존재하면 덮어쓰기) + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING); + } + + return savedFilename; // 데이터베이스에 저장할 파일명 리턴 + + } catch (IOException e) { + throw new RuntimeException("파일 저장 실패: " + originalFilename, e); + } + } + + /** + * 파일 삭제 + * @param filename 삭제할 파일명 + */ + public void delete(String filename) { + try { + Path file = this.rootLocation.resolve(filename).normalize(); + Files.deleteIfExists(file); + } catch (IOException e) { + throw new RuntimeException("파일 삭제 실패: " + filename, e); + } + } + + /** + * 저장된 루트 경로 반환 + * @return Path + */ + public Path getRootLocation() { + return this.rootLocation; + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java new file mode 100644 index 00000000..d3474ba0 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostService.java @@ -0,0 +1,45 @@ +package org.sejongisc.backend.board.service; + +import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.dto.PostRequest; +import org.sejongisc.backend.board.dto.PostResponse; +import org.sejongisc.backend.board.entity.BoardType; +import org.springframework.data.domain.Page; + +import java.util.UUID; + +public interface PostService { + + // 게시물 작성 + void savePost(PostRequest request, UUID userId); + + // 게시물 수정 + void updatePost(PostRequest request, UUID postId, UUID userId); + + // 게시물 삭제 + void deletePost(UUID postId, UUID userId); + + // 게시물 조회 (전체) + Page getPosts(BoardType boardType, int pageNumber, int pageSize); + + // 게시물 검색 (제목/내용) + Page searchPosts(String keyword, int pageNumber, int pageSize); + + // 게시물 상세 조회 + PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize); + + // 댓글 작성 + void createComment(CommentRequest request, UUID userId); + + // 댓글 수정 + void updateComment(CommentRequest request, UUID commentId, UUID userId); + + // 댓글 삭제 + void deleteComment(UUID commentId, UUID userId); + + // 좋아요 + void toggleLike(UUID postId, UUID userId); + + // 북마크 + void toggleBookmark(UUID postId, UUID userId); +} diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java new file mode 100644 index 00000000..e40c9e4a --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -0,0 +1,397 @@ +package org.sejongisc.backend.board.service; + +import jakarta.persistence.OptimisticLockException; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.board.entity.*; +import org.sejongisc.backend.board.dto.*; +import org.sejongisc.backend.board.repository.*; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Transactional +public class PostServiceImpl implements PostService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + private final PostBookmarkRepository postBookmarkRepository; + private final PostAttachmentRepository postAttachmentRepository; + private final FileUploadService fileUploadService; + + // 게시물 작성 + @Override + @Transactional + public void savePost(PostRequest request, UUID userId) { + Post post = Post.builder() + .user(userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND))) + .boardType(request.getBoardType()) + .title(request.getTitle()) + .content(request.getContent()) + .postType(request.getPostType()) + .build(); + + post = postRepository.save(post); + + // 첨부파일 저장 + List files = request.getFiles(); + if (files != null && !files.isEmpty()) { + for (MultipartFile file : files) { + if (file != null && !file.isEmpty()) { + String savedFilename = fileUploadService.store(file); + String filePath = fileUploadService.getRootLocation().resolve(savedFilename).toString(); + + PostAttachment attachment = PostAttachment.builder() + .post(post) + .savedFilename(savedFilename) + .originalFilename(file.getOriginalFilename()) + .filePath(filePath) + .build(); + postAttachmentRepository.save(attachment); + } + } + } + } + + // 게시물 수정 + @Override + @Transactional + public void updatePost(PostRequest request, UUID postId, UUID userId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + if (!post.getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_POST_OWNER); + } + + post.setTitle(request.getTitle()); + post.setContent(request.getContent()); + post.setPostType(request.getPostType()); + + // 기존 파일 조회 및 삭제 + List existingAttachments = postAttachmentRepository.findAllByPostPostId(postId); + + // DB에서 첨부파일 정보 일괄 삭제 + postAttachmentRepository.deleteAllByPostPostId(postId); + + // 물리적 파일 삭제 + for (PostAttachment attachment : existingAttachments) { + fileUploadService.delete(attachment.getSavedFilename()); + } + + // 새 파일 저장 + List files = request.getFiles(); + if (files != null && !files.isEmpty()) { + for (MultipartFile file : files) { + if (file != null && !file.isEmpty()) { + String savedFilename = fileUploadService.store(file); + String filePath = fileUploadService.getRootLocation().resolve(savedFilename).toString(); + + PostAttachment attachment = PostAttachment.builder() + .post(post) + .savedFilename(savedFilename) + .originalFilename(file.getOriginalFilename()) + .filePath(filePath) + .build(); + postAttachmentRepository.save(attachment); + } + } + } + } + + // 게시물 삭제 + @Override + @Transactional + public void deletePost(UUID postId, UUID userId) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 작성자 확인 + if (!post.getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_POST_OWNER); + } + + // DB에서 첨부파일 정보 조회 + List attachments = postAttachmentRepository.findAllByPostPostId(postId); + + // DB에서 첨부파일 정보 삭제 + postAttachmentRepository.deleteAllByPostPostId(postId); + + // 물리적 파일 삭제 + for (PostAttachment attachment : attachments) { + fileUploadService.delete(attachment.getSavedFilename()); + } + + // 댓글 삭제 + commentRepository.deleteAllByPostPostId(post.getPostId()); + postLikeRepository.deleteAllByPostPostId(post.getPostId()); + postBookmarkRepository.deleteAllByPostPostId(post.getPostId()); + + // 게시물 삭제 + postRepository.delete(post); + } + + // 게시물 조회 (전체 | 금융 IT | 자산 운용) + @Override + @Transactional(readOnly = true) + public Page getPosts(BoardType boardType, int pageNumber, int pageSize) { + Pageable pageable = PageRequest.of( + pageNumber, + pageSize, + Sort.by(Direction.DESC, "createdDate") + ); + + // 게시판 타입에 따른 게시물 조회 + Page posts = postRepository.findAllByBoardType(boardType, pageable); + + return posts.map(this::mapToPostResponse); + } + + // 게시물 검색 (제목/내용) + @Transactional(readOnly = true) + @Override + public Page searchPosts(String keyword, int pageNumber, int pageSize) { + Pageable pageable = PageRequest.of( + pageNumber, + pageSize, + Sort.by(Direction.DESC, "createdDate") + ); + + // 해당 키워드가 들어간 게시물 검색 + Page posts = postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + keyword, keyword, pageable); + + return posts.map(this::mapToPostResponse); + } + + // 게시물 상세 조회 + @Override + @Transactional(readOnly = true) + public PostResponse getPostDetail(UUID postId, int pageNumber, int pageSize) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 댓글 페이징을 위한 Pageable 객체 생성 + Pageable pageable = PageRequest.of( + pageNumber, + pageSize, + Sort.by(Sort.Direction.ASC, "createdDate") + ); + + // 해당 게시물의 댓글 목록을 '페이징'하여 조회 + Page comments = commentRepository.findAllByPostPostId(postId, pageable); + + // Page -> Page DTO로 변환 + Page commentResponses = comments.map(CommentResponse::of); + + // 첨부 파일 조회 + List attachmentResponses = postAttachmentRepository.findAllByPostPostId(postId) + .stream() + .map(PostAttachmentResponse::of) + .toList(); + + // PostResponse DTO를 직접 빌드하여 반환 + return PostResponse.builder() + .postId(post.getPostId()) + .boardType(post.getBoardType()) + .user(post.getUser()) + .title(post.getTitle()) + .content(post.getContent()) + .postType(post.getPostType()) + .bookmarkCount(post.getBookmarkCount()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdDate(post.getCreatedDate()) + .updatedDate(post.getUpdatedDate()) + .comments(commentResponses) + .attachments(attachmentResponses) + .build(); + } + + // 댓글 작성 + @Override + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void createComment(CommentRequest request, UUID userId) { + // 게시글 조회 + Post post = postRepository.findById(request.getPostId()) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 작성자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // comment 엔티티 저장 + Comment comment = Comment.builder() + .post(post) + .user(user) + .content(request.getContent()) + .build(); + + commentRepository.save(comment); + + // 게시글의 댓글 수 1 증가 + post.setCommentCount(post.getCommentCount() + 1); + } + + // 댓글 수정 + @Override + @Transactional + public void updateComment(CommentRequest request, UUID commentId, UUID userId) { + // comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 작성자 확인 + if (!comment.getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); + } + + // 내용 업데이트 + comment.setContent(request.getContent()); + } + + // 댓글 삭제 + @Override + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void deleteComment(UUID commentId, UUID userId) { + // comment 조회 + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + + // 관리자 확인 + boolean isAdmin = userRepository.findById(userId) + .map(user -> user.getRole() == Role.PRESIDENT || user.getRole() == Role.VICE_PRESIDENT) + .orElse(false); + + // 작성자 확인 (관리자는 통과) + if (!comment.getUser().getUserId().equals(userId) && !isAdmin) { + throw new CustomException(ErrorCode.INVALID_COMMENT_OWNER); + } + + // 게시글의 댓글 수 1 감소 + Post post = postRepository.findById(comment.getPost().getPostId()) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + post.setCommentCount(post.getCommentCount() - 1); + + // comment 삭제 + commentRepository.delete(comment); + } + + // 좋아요 등록/삭제 + @Override + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void toggleLike(UUID postId, UUID userId) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이미 좋아요를 눌렀는지 확인 + Optional existingLike = postLikeRepository.findByPostPostIdAndUserUserId(postId, userId); + + if (existingLike.isPresent()) { + // 좋아요가 이미 있으면 -> 삭제 (좋아요 취소) + postLikeRepository.delete(existingLike.get()); + post.setLikeCount(post.getLikeCount() - 1); // Post 엔티티 카운트 감소 + } else { + // 좋아요가 없으면 -> 생성 (좋아요) + PostLike newLike = PostLike.builder() + .post(post) + .user(user) + .build(); + postLikeRepository.save(newLike); + post.setLikeCount(post.getLikeCount() + 1); // Post 엔티티 카운트 증가 + } + } + + // 북마크 등록/삭제 + @Override + @Transactional + @Retryable( + value = { ObjectOptimisticLockingFailureException.class, OptimisticLockException.class }, + maxAttempts = 5, + backoff = @Backoff(delay = 100) + ) + public void toggleBookmark(UUID postId, UUID userId) { + // 게시물 조회 + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + + // 유저 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 이미 북마크를 했는지 확인 + Optional existingBookmark = postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId); + + if (existingBookmark.isPresent()) { + // 북마크가 이미 있으면 -> 삭제 (북마크 취소) + postBookmarkRepository.delete(existingBookmark.get()); + post.setBookmarkCount(post.getBookmarkCount() - 1); // Post 엔티티 카운트 감소 + } else { + // 북마크가 없으면 -> 생성 (북마크) + PostBookmark newBookmark = PostBookmark.builder() + .post(post) + .user(user) + .build(); + postBookmarkRepository.save(newBookmark); + post.setBookmarkCount(post.getBookmarkCount() + 1); // Post 엔티티 카운트 증가 + } + } + + private PostResponse mapToPostResponse(Post post) { + return PostResponse.builder() + .postId(post.getPostId()) + .user(post.getUser()) + .boardType(post.getBoardType()) + .title(post.getTitle()) + .content(post.getContent()) + .postType(post.getPostType()) + .bookmarkCount(post.getBookmarkCount()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentCount()) + .createdDate(post.getCreatedDate()) + .updatedDate(post.getUpdatedDate()) + .build(); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java index 40b643af..b9ae8e1f 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java @@ -63,6 +63,7 @@ public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( "org.sejongisc.backend.auth.entity", "org.sejongisc.backend.backtest.entity", "org.sejongisc.backend.betting.entity", + "org.sejongisc.backend.board.entity", "org.sejongisc.backend.common.entity.postgres", "org.sejongisc.backend.point.entity", "org.sejongisc.backend.stock.entity", diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 82515797..f38073a5 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -72,7 +72,17 @@ public enum ErrorCode { BET_ROUND_CLOSED(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."), BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."), BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."), - BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다."); + BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다."), + + // BOARD + + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시물을 찾을 수 없습니다."), + + INVALID_POST_OWNER(HttpStatus.FORBIDDEN, "게시물 수정/삭제 권한이 없습니다."), + + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글을 찾을 수 없습니다."), + + INVALID_COMMENT_OWNER(HttpStatus.FORBIDDEN, "댓글 수정/삭제 권한이 없습니다."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java index 98a720aa..f0e3cbeb 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java @@ -1,5 +1,6 @@ package org.sejongisc.backend.user.entity; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.*; import lombok.*; import org.sejongisc.backend.auth.entity.UserOauthAccount; @@ -16,6 +17,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class User extends BasePostgresEntity{ @Id diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/FileUploadServiceTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/FileUploadServiceTest.java new file mode 100644 index 00000000..582e8769 --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/board/service/FileUploadServiceTest.java @@ -0,0 +1,98 @@ +package org.sejongisc.backend.board.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@ExtendWith(MockitoExtension.class) +class FileUploadServiceTest { + + @TempDir + Path tempDir; + + FileUploadService fileUploadService; + + @BeforeEach + void setUp() { + fileUploadService = new FileUploadService(); + + ReflectionTestUtils.setField(fileUploadService, "rootLocation", tempDir); + } + + @Test + @DisplayName("파일 저장 성공") + void store_success() throws IOException { + String originalFilename = "test-file.txt"; + MockMultipartFile file = new MockMultipartFile( + "file", + originalFilename, + "text/plain", + "Hello, World!".getBytes() + ); + + String savedFilename = fileUploadService.store(file); + + assertThat(savedFilename).endsWith("_" + originalFilename); + assertThat(savedFilename.length()).isGreaterThan(originalFilename.length() + 36); + + Path expectedFilePath = tempDir.resolve(savedFilename); + assertThat(Files.exists(expectedFilePath)).isTrue(); + assertThat(Files.readAllBytes(expectedFilePath)).isEqualTo("Hello, World!".getBytes()); + } + + @Test + @DisplayName("빈 파일 저장 시 예외 발생") + void store_emptyFile_throwException() { + MockMultipartFile emptyFile = new MockMultipartFile( + "file", + "empty.txt", + "text/plain", + new byte[0] + ); + + assertThatThrownBy(() -> fileUploadService.store(emptyFile)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("빈 파일은 저장할 수 없습니다."); + } + + @Test + @DisplayName("파일 삭제 성공") + void delete_success() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "delete-me.txt", "text/plain", "data".getBytes()); + String savedFilename = fileUploadService.store(file); + Path filePath = tempDir.resolve(savedFilename); + + assertThat(Files.exists(filePath)).isTrue(); + + fileUploadService.delete(savedFilename); + + assertThat(Files.exists(filePath)).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 파일 삭제 시 예외 발생 안 함") + void delete_nonExistingFile_noException() { + String nonExistingFilename = "non-existing-file.txt"; + Path filePath = tempDir.resolve(nonExistingFilename); + assertThat(Files.exists(filePath)).isFalse(); + + // FileUploadService.java의 delete는 예외를 throw하지 않으므로, + // 예외가 발생하지 않는지 확인합니다. + assertDoesNotThrow(() -> + fileUploadService.delete(nonExistingFilename) + ); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java new file mode 100644 index 00000000..5b1bb693 --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java @@ -0,0 +1,440 @@ +package org.sejongisc.backend.board.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; +import org.sejongisc.backend.board.dto.CommentRequest; +import org.sejongisc.backend.board.dto.PostRequest; +import org.sejongisc.backend.board.dto.PostResponse; +import org.sejongisc.backend.board.entity.*; +import org.sejongisc.backend.board.repository.*; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.springframework.data.domain.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PostServiceImplTest { + + @Mock UserRepository userRepository; + @Mock PostRepository postRepository; + @Mock CommentRepository commentRepository; + @Mock PostLikeRepository postLikeRepository; + @Mock PostBookmarkRepository postBookmarkRepository; + @Mock PostAttachmentRepository postAttachmentRepository; + @Mock FileUploadService fileUploadService; + + @InjectMocks + PostServiceImpl postService; + + UUID userId; + User user; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + user = User.builder().userId(userId).role(Role.TEAM_MEMBER).build(); + } + + private PostRequest samplePostRequestWithFiles() { + MockMultipartFile f = new MockMultipartFile("files", "note.txt", "text/plain", + "hello".getBytes(StandardCharsets.UTF_8)); + PostRequest req = new PostRequest(); + req.setBoardType(BoardType.GENERAL); + req.setPostType(PostType.NORMAL); + req.setTitle("제목"); + req.setContent("내용"); + req.setFiles(List.of(f)); + return req; + } + + @Test + @DisplayName("게시글 저장 - 첨부파일 저장까지") + void savePost_withFiles() { + PostRequest req = samplePostRequestWithFiles(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + UUID postId = UUID.randomUUID(); + Answer saveAnswer = inv -> { + Post p = inv.getArgument(0); + return Post.builder() + .postId(postId) + .user(p.getUser()) + .boardType(p.getBoardType()) + .title(p.getTitle()) + .content(p.getContent()) + .postType(p.getPostType()) + .build(); + }; + when(postRepository.save(any(Post.class))).thenAnswer(saveAnswer); + + when(fileUploadService.store(any(MultipartFile.class))).thenReturn("stored-note.txt"); + when(fileUploadService.getRootLocation()).thenReturn(java.nio.file.Path.of("/data/upload")); + + postService.savePost(req, userId); + + verify(postRepository, times(1)).save(any(Post.class)); + verify(postAttachmentRepository, times(1)).save(argThat(att -> + "stored-note.txt".equals(att.getSavedFilename()) + && "note.txt".equals(att.getOriginalFilename()) + )); + } + + @Test + @DisplayName("게시글 수정 - 기존 첨부 삭제 후 신규 저장") + void updatePost_replaceFiles() { + UUID postId = UUID.randomUUID(); + PostRequest req = samplePostRequestWithFiles(); + + Post existing = Post.builder() + .postId(postId).user(user) + .title("old").content("old").postType(PostType.NORMAL) + .boardType(BoardType.GENERAL).build(); + + when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); + when(fileUploadService.store(any(MultipartFile.class))).thenReturn("new.txt"); + when(fileUploadService.getRootLocation()).thenReturn(java.nio.file.Path.of("/data/upload")); + when(postAttachmentRepository.findAllByPostPostId(postId)) + .thenReturn(List.of(PostAttachment.builder() + .post(existing).savedFilename("old.txt").build())); + + postService.updatePost(req, postId, userId); + + verify(fileUploadService).delete("old.txt"); + verify(postAttachmentRepository).deleteAllByPostPostId(postId); + verify(postAttachmentRepository).save(argThat(a -> + a.getPost().getPostId().equals(postId) && a.getSavedFilename().equals("new.txt"))); + assertThat(existing.getTitle()).isEqualTo("제목"); + assertThat(existing.getContent()).isEqualTo("내용"); + } + + @Test + @DisplayName("게시글 수정 - 소유자 아님 -> 예외") + void updatePost_notOwner_throws() { + UUID postId = UUID.randomUUID(); + User other = User.builder().userId(UUID.randomUUID()).build(); + Post existing = Post.builder().postId(postId).user(other).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> postService.updatePost(new PostRequest(), postId, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.INVALID_POST_OWNER.getMessage()); + } + + @Test + @DisplayName("게시글 삭제 - 첨부/댓글/좋아요/북마크 삭제 포함") + void deletePost_allRelated() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).build(); + + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(postAttachmentRepository.findAllByPostPostId(postId)) + .thenReturn(List.of( + PostAttachment.builder().post(post).savedFilename("a.txt").build(), + PostAttachment.builder().post(post).savedFilename("b.txt").build() + )); + + postService.deletePost(postId, userId); + + verify(fileUploadService).delete("a.txt"); + verify(fileUploadService).delete("b.txt"); + verify(postAttachmentRepository).deleteAllByPostPostId(postId); + verify(commentRepository).deleteAllByPostPostId(postId); + verify(postLikeRepository).deleteAllByPostPostId(postId); + verify(postBookmarkRepository).deleteAllByPostPostId(postId); + verify(postRepository).delete(post); + } + + @Test + @DisplayName("게시글 삭제 - 소유자 아님 -> 예외") + void deletePost_notOwner_throws() { + UUID postId = UUID.randomUUID(); + User other = User.builder().userId(UUID.randomUUID()).build(); + Post existing = Post.builder().postId(postId).user(other).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> postService.deletePost(postId, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.INVALID_POST_OWNER.getMessage()); + } + + @Test + @DisplayName("게시글 목록 조회 - 매핑 검사") + void getPosts_mapping() { + Post p = Post.builder() + .postId(UUID.randomUUID()) + .user(user) + .boardType(BoardType.GENERAL) + .title("t") + .content("c") + .postType(PostType.NORMAL) + .bookmarkCount(1) + .likeCount(2) + .commentCount(3) + .build(); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(postRepository.findAllByBoardType(eq(BoardType.GENERAL), pageableCaptor.capture())) + .thenReturn(new PageImpl<>(List.of(p))); + + Page page = postService.getPosts(BoardType.GENERAL, 0, 20); + + assertThat(page.getContent()).hasSize(1); + PostResponse pr = page.getContent().get(0); + assertThat(pr.getTitle()).isEqualTo("t"); + assertThat(pr.getLikeCount()).isEqualTo(2); + + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "createdDate")); + } + + @Test + @DisplayName("게시글 검색 - 매핑 검사") + void searchPosts_mapping() { + Post p = Post.builder() + .postId(UUID.randomUUID()) + .user(user) + .boardType(BoardType.GENERAL) + .title("find me") + .content("c") + .postType(PostType.NORMAL) + .build(); + String keyword = "find"; + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(postRepository.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCase( + eq(keyword), eq(keyword), pageableCaptor.capture())) + .thenReturn(new PageImpl<>(List.of(p))); + + Page page = postService.searchPosts(keyword, 0, 20); + + assertThat(page.getContent()).hasSize(1); + assertThat(page.getContent().get(0).getTitle()).isEqualTo("find me"); + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.DESC, "createdDate")); + } + + @Test + @DisplayName("게시글 상세 조회 - 댓글(페이징)과 첨부파일 포함") + void getPostDetail_withCommentsAndAttachments() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder() + .postId(postId).user(user).title("detail").content("detail content") + .build(); + + Comment comment = Comment.builder().commentId(UUID.randomUUID()).post(post).user(user) + .content("comment 1").build(); + Page commentPage = new PageImpl<>(List.of(comment)); + + PostAttachment attachment = PostAttachment.builder() + .postAttachmentId(UUID.randomUUID()).post(post).savedFilename("file.txt").originalFilename("orig.txt") + .build(); + List attachmentList = List.of(attachment); + + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + when(commentRepository.findAllByPostPostId(eq(postId), pageableCaptor.capture())) + .thenReturn(commentPage); + when(postAttachmentRepository.findAllByPostPostId(postId)) + .thenReturn(attachmentList); + + PostResponse response = postService.getPostDetail(postId, 0, 10); + + assertThat(response.getPostId()).isEqualTo(postId); + assertThat(response.getTitle()).isEqualTo("detail"); + + assertThat(pageableCaptor.getValue().getSort()) + .isEqualTo(Sort.by(Sort.Direction.ASC, "createdDate")); + + assertThat(response.getComments()).isNotNull(); + assertThat(response.getComments().getTotalElements()).isEqualTo(1); + assertThat(response.getComments().getContent().get(0).getContent()).isEqualTo("comment 1"); + + assertThat(response.getAttachments()).isNotNull(); + assertThat(response.getAttachments()).hasSize(1); + assertThat(response.getAttachments().get(0).getOriginalFilename()).isEqualTo("orig.txt"); + } + + + @Test + @DisplayName("댓글 생성 - 댓글수 증가") + void createComment_increaseCount() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).commentCount(0).build(); + + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + CommentRequest req = new CommentRequest(); + req.setPostId(postId); + req.setContent("hi"); + + postService.createComment(req, userId); + + verify(commentRepository).save(any(Comment.class)); + assertThat(post.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("댓글 수정 - 성공") + void updateComment_success() { + UUID commentId = UUID.randomUUID(); + Comment comment = Comment.builder().commentId(commentId).user(user).content("old content") + .build(); + CommentRequest req = new CommentRequest(); + req.setContent("new content"); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + postService.updateComment(req, commentId, userId); + + assertThat(comment.getContent()).isEqualTo("new content"); + } + + @Test + @DisplayName("댓글 수정 - 소유자 아님 -> 예외") + void updateComment_notOwner_throws() { + UUID commentId = UUID.randomUUID(); + User other = User.builder().userId(UUID.randomUUID()).build(); + Comment comment = Comment.builder().commentId(commentId).user(other).content("old content") + .build(); + CommentRequest req = new CommentRequest(); + req.setContent("new content"); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> postService.updateComment(req, commentId, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); + } + + + @Test + @DisplayName("댓글 삭제 - 작성자 또는 관리자만 가능, 댓글수 감소") + void deleteComment_ownerOrAdmin() { + UUID commentId = UUID.randomUUID(); + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).commentCount(3).build(); + Comment comment = Comment.builder().commentId(commentId).post(post).user(user).content("c") + .build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + + postService.deleteComment(commentId, userId); + verify(commentRepository).delete(comment); + assertThat(post.getCommentCount()).isEqualTo(2); + + reset(commentRepository, userRepository, postRepository); + User admin = User.builder().userId(UUID.randomUUID()).role(Role.PRESIDENT).build(); + User otherUser = User.builder().userId(UUID.randomUUID()).role(Role.TEAM_MEMBER).build(); + Comment othersComment = Comment.builder().commentId(commentId).post(post) + .user(otherUser) + .content("c").build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(othersComment)); + when(userRepository.findById(admin.getUserId())).thenReturn(Optional.of(admin)); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + + post.setCommentCount(5); + postService.deleteComment(commentId, admin.getUserId()); + verify(commentRepository).delete(othersComment); + assertThat(post.getCommentCount()).isEqualTo(4); + } + + @Test + @DisplayName("댓글 삭제 - 소유자/관리자 아님 -> 예외") + void deleteComment_notOwnerOrAdmin_throws() { + UUID commentId = UUID.randomUUID(); + UUID postId = UUID.randomUUID(); + User other = User.builder().userId(UUID.randomUUID()).role(Role.TEAM_MEMBER).build(); + Post post = Post.builder().postId(postId).user(other).commentCount(1).build(); + Comment comment = Comment.builder().commentId(commentId).post(post).user(other).build(); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> postService.deleteComment(commentId, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.INVALID_COMMENT_OWNER.getMessage()); + + verify(postRepository, never()).findById(any()); + assertThat(post.getCommentCount()).isEqualTo(1); + } + + @Test + @DisplayName("좋아요 토글 - 새로 추가") + void toggleLike_add() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).likeCount(0).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) + .thenReturn(Optional.empty()); + + postService.toggleLike(postId, userId); + + verify(postLikeRepository).save(argThat(l -> + l.getPost().getPostId().equals(postId) && l.getUser().getUserId().equals(userId))); + assertThat(post.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("좋아요 토글 - 취소") + void toggleLike_remove() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).likeCount(2).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + PostLike like = PostLike.builder().post(post).user(user).build(); + when(postLikeRepository.findByPostPostIdAndUserUserId(postId, userId)) + .thenReturn(Optional.of(like)); + + postService.toggleLike(postId, userId); + + verify(postLikeRepository).delete(like); + assertThat(post.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("북마크 토글 - 추가/취소") + void toggleBookmark_add_and_remove() { + UUID postId = UUID.randomUUID(); + Post post = Post.builder().postId(postId).user(user).bookmarkCount(0).build(); + when(postRepository.findById(postId)).thenReturn(Optional.of(post)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) + .thenReturn(Optional.empty()); + postService.toggleBookmark(postId, userId); + verify(postBookmarkRepository).save(argThat(b -> + b.getPost().getPostId().equals(postId) && b.getUser().getUserId().equals(userId))); + assertThat(post.getBookmarkCount()).isEqualTo(1); + + reset(postBookmarkRepository); + PostBookmark existingBookmark = PostBookmark.builder().post(post).user(user).build(); + when(postBookmarkRepository.findByPostPostIdAndUserUserId(postId, userId)) + .thenReturn(Optional.of(existingBookmark)); + + postService.toggleBookmark(postId, userId); + + verify(postBookmarkRepository).delete(existingBookmark); + assertThat(post.getBookmarkCount()).isEqualTo(0); + } +} \ No newline at end of file