From de9d3e2c66aaa50bd18268185329a045c2306b78 Mon Sep 17 00:00:00 2001 From: Juhyeon Lee Date: Sat, 20 Dec 2025 22:46:17 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=EA=B8=B0=EB=A1=9D=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=EC=A1=B0=ED=9A=8C/=EC=88=98=EC=A0=95/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/post/controller/PostController.java | 60 +++++- ...esponseDTO.java => PostIdResponseDTO.java} | 2 +- ...ateRequestDTO.java => PostRequestDTO.java} | 25 +-- .../backend/api/post/dto/PostResponseDTO.java | 28 ++- .../backend/api/post/entity/Post.java | 27 +-- .../api/post/repository/QuoteRepository.java | 13 ++ .../backend/api/post/service/PostService.java | 176 ++++++++++++++++-- .../backend/common/response/ErrorStatus.java | 3 + .../common/response/SuccessStatus.java | 3 + 9 files changed, 278 insertions(+), 59 deletions(-) rename src/main/java/com/moongeul/backend/api/post/dto/{PostCreateResponseDTO.java => PostIdResponseDTO.java} (87%) rename src/main/java/com/moongeul/backend/api/post/dto/{PostCreateRequestDTO.java => PostRequestDTO.java} (76%) create mode 100644 src/main/java/com/moongeul/backend/api/post/repository/QuoteRepository.java diff --git a/src/main/java/com/moongeul/backend/api/post/controller/PostController.java b/src/main/java/com/moongeul/backend/api/post/controller/PostController.java index 93a5b56..5fa18f7 100644 --- a/src/main/java/com/moongeul/backend/api/post/controller/PostController.java +++ b/src/main/java/com/moongeul/backend/api/post/controller/PostController.java @@ -1,7 +1,8 @@ package com.moongeul.backend.api.post.controller; -import com.moongeul.backend.api.post.dto.PostCreateRequestDTO; -import com.moongeul.backend.api.post.dto.PostCreateResponseDTO; +import com.moongeul.backend.api.post.dto.PostRequestDTO; +import com.moongeul.backend.api.post.dto.PostIdResponseDTO; +import com.moongeul.backend.api.post.dto.PostResponseDTO; import com.moongeul.backend.api.post.service.PostService; import com.moongeul.backend.common.response.ApiResponse; import com.moongeul.backend.common.response.SuccessStatus; @@ -36,10 +37,59 @@ public class PostController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 도서를 찾을 수 없습니다.") }) @PostMapping("/create") - public ResponseEntity> createPost(@AuthenticationPrincipal UserDetails userDetails, - @Valid @RequestBody PostCreateRequestDTO postCreateRequestDTO) { + public ResponseEntity> createPost(@AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody PostRequestDTO postRequestDTO) { - PostCreateResponseDTO response = postService.createPost(postCreateRequestDTO, userDetails.getUsername()); + PostIdResponseDTO response = postService.createPost(postRequestDTO, userDetails.getUsername()); return ApiResponse.success(SuccessStatus.CREATE_POST_SUCCESS, response); } + + @Operation( + summary = "기록(게시글) 상세 조회 API", + description = "기록(게시글)의 상세 조회 API 입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "기록(게시글) 상세 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 기록(게시글)을 찾을 수 없습니다.") + }) + @GetMapping("/{id}") + public ResponseEntity> getPost(@PathVariable Long id) { + + PostResponseDTO response = postService.getPostDetail(id); + return ApiResponse.success(SuccessStatus.GET_POST_SUCCESS, response); + } + + @Operation( + summary = "기록(게시글) 수정 API", + description = "기록(게시글)을 수정하는 API 입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "기록(게시글) 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "수정하려는 회원의 게시글이 아닙니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 기록(게시글)을 찾을 수 없습니다.") + }) + @PutMapping("/{id}") + public ResponseEntity> updatePost(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long id, + @Valid @RequestBody PostRequestDTO postRequestDTO) { + + PostIdResponseDTO response = postService.updatePost(id, userDetails.getUsername(), postRequestDTO); + return ApiResponse.success(SuccessStatus.UPDATE_POST_SUCCESS, response); + } + + @Operation( + summary = "기록(게시글) 삭제 API", + description = "기록(게시글)을 삭제하는 API 입니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "기록(게시글) 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 기록(게시글)을 찾을 수 없습니다.") + }) + @DeleteMapping("/{id}") + public ResponseEntity> deletePost(@AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long id) { + + postService.deletePost(id, userDetails.getUsername()); + return ApiResponse.success_only(SuccessStatus.DELETE_POST_SUCCESS); + } } diff --git a/src/main/java/com/moongeul/backend/api/post/dto/PostCreateResponseDTO.java b/src/main/java/com/moongeul/backend/api/post/dto/PostIdResponseDTO.java similarity index 87% rename from src/main/java/com/moongeul/backend/api/post/dto/PostCreateResponseDTO.java rename to src/main/java/com/moongeul/backend/api/post/dto/PostIdResponseDTO.java index ab276b9..43d47fa 100644 --- a/src/main/java/com/moongeul/backend/api/post/dto/PostCreateResponseDTO.java +++ b/src/main/java/com/moongeul/backend/api/post/dto/PostIdResponseDTO.java @@ -9,7 +9,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class PostCreateResponseDTO { +public class PostIdResponseDTO { private Long postId; // 생성된 Post id } diff --git a/src/main/java/com/moongeul/backend/api/post/dto/PostCreateRequestDTO.java b/src/main/java/com/moongeul/backend/api/post/dto/PostRequestDTO.java similarity index 76% rename from src/main/java/com/moongeul/backend/api/post/dto/PostCreateRequestDTO.java rename to src/main/java/com/moongeul/backend/api/post/dto/PostRequestDTO.java index 99c6434..efb480f 100644 --- a/src/main/java/com/moongeul/backend/api/post/dto/PostCreateRequestDTO.java +++ b/src/main/java/com/moongeul/backend/api/post/dto/PostRequestDTO.java @@ -5,7 +5,6 @@ import com.moongeul.backend.api.category.entity.Category; import com.moongeul.backend.api.post.entity.Post; import com.moongeul.backend.api.post.entity.PostVisibility; -import com.moongeul.backend.api.post.entity.Quote; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,14 +12,13 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; @Getter @Builder @NoArgsConstructor @AllArgsConstructor -public class PostCreateRequestDTO { +public class PostRequestDTO { /* 드롭다운 - 필수 입력*/ @NotNull(message = "공개여부는 필수입니다") @@ -51,8 +49,9 @@ public class PostCreateRequestDTO { private List quotes; // 인상깊은구절 @Getter + @Builder public static class QuoteRequestDTO { - private String quote; // 인용문 내용 + private String quoteContent; // 인용문 내용 private Integer pageNumber; // 페이지 번호 } @@ -62,7 +61,7 @@ public Post toEntity(Category category, Member member, Book book) { Double finalRating = (this.rating != null) ? this.rating : 5.0; Integer finalPage = (this.page != null) ? this.page : 300; - Post post = Post.builder() + return Post.builder() .postVisibility(this.postVisibility) .category(category) .readDate(this.readDate) @@ -72,22 +71,6 @@ public Post toEntity(Category category, Member member, Book book) { .member(member) .book(book) .build(); - - // Quote 처리 - if (this.quotes != null && !this.quotes.isEmpty()) { - List quoteList = new ArrayList<>(); - for (QuoteRequestDTO quoteDTO : this.quotes) { - Quote quote = Quote.builder() - .quoteContent(quoteDTO.getQuote()) - .pageNumber(quoteDTO.getPageNumber()) - .post(post) - .build(); - quoteList.add(quote); - - post.addQuotes(quoteList); - } - } - return post; } } diff --git a/src/main/java/com/moongeul/backend/api/post/dto/PostResponseDTO.java b/src/main/java/com/moongeul/backend/api/post/dto/PostResponseDTO.java index fcf4b7f..bd35ca5 100644 --- a/src/main/java/com/moongeul/backend/api/post/dto/PostResponseDTO.java +++ b/src/main/java/com/moongeul/backend/api/post/dto/PostResponseDTO.java @@ -1,6 +1,5 @@ package com.moongeul.backend.api.post.dto; -import com.moongeul.backend.api.book.dto.BookDTO; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,12 +13,27 @@ @AllArgsConstructor public class PostResponseDTO { - // 필수 - private BookDTO bookDTO; // 필요 - 책 제목/작가/출판사 - private double rating; - - // 선택 + private BookInfo bookInfo; // 책 정보 + private double rating; // 별점 private String content; // 감상평 - private List quotes; // 인상깊은구절 리스트 + private List quotes; // 인상깊은구절 리스트 + + @Getter + @Builder + public static class BookInfo{ + + private String isbn; // ISBN + private String bookImage; // 표지 이미지 + private String title; // 책 제목 + private String author; // 저자 + private String publisher; // 출판사 + } + + @Getter + @Builder + public static class QuoteDTO { + private String quoteContent; // 인용문 내용 + private Integer pageNumber; // 페이지 번호 + } } diff --git a/src/main/java/com/moongeul/backend/api/post/entity/Post.java b/src/main/java/com/moongeul/backend/api/post/entity/Post.java index fcf67ff..c1db527 100644 --- a/src/main/java/com/moongeul/backend/api/post/entity/Post.java +++ b/src/main/java/com/moongeul/backend/api/post/entity/Post.java @@ -8,8 +8,6 @@ import lombok.*; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; @Entity @Getter @@ -27,10 +25,6 @@ public class Post extends BaseTimeEntity { private Integer page; // 페이지 수 private String content; // 감상평 - @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List quotes = new ArrayList<>(); // 인상깊은구절 - @Enumerated(EnumType.STRING) private PostVisibility postVisibility; // 공개여부 @@ -46,11 +40,20 @@ public class Post extends BaseTimeEntity { @JoinColumn(name = "book_isbn", nullable = false) private Book book; - public void addQuote(Quote quote) { - quotes.add(quote); - } - - public void addQuotes(List quoteList) { - quoteList.forEach(this::addQuote); + // 게시글 수정 + public void update(LocalDate readDate, + Double rating, + Integer page, + String content, + PostVisibility postVisibility, + Category category, + Book book) { + this.readDate = readDate; + this.rating = rating; + this.page = page; + this.content = content; + this.postVisibility = postVisibility; + this.category = category; + this.book = book; } } diff --git a/src/main/java/com/moongeul/backend/api/post/repository/QuoteRepository.java b/src/main/java/com/moongeul/backend/api/post/repository/QuoteRepository.java new file mode 100644 index 0000000..27c07c1 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/post/repository/QuoteRepository.java @@ -0,0 +1,13 @@ +package com.moongeul.backend.api.post.repository; + +import com.moongeul.backend.api.post.entity.Quote; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface QuoteRepository extends JpaRepository { + + List findByPostId(Long postId); + + void deleteAllByPostId(Long postId); +} diff --git a/src/main/java/com/moongeul/backend/api/post/service/PostService.java b/src/main/java/com/moongeul/backend/api/post/service/PostService.java index 6addb45..879baeb 100644 --- a/src/main/java/com/moongeul/backend/api/post/service/PostService.java +++ b/src/main/java/com/moongeul/backend/api/post/service/PostService.java @@ -4,19 +4,26 @@ import com.moongeul.backend.api.book.repository.BookRepository; import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.repository.MemberRepository; -import com.moongeul.backend.api.post.dto.PostCreateRequestDTO; -import com.moongeul.backend.api.post.dto.PostCreateResponseDTO; +import com.moongeul.backend.api.post.dto.PostRequestDTO; +import com.moongeul.backend.api.post.dto.PostIdResponseDTO; import com.moongeul.backend.api.category.entity.Category; +import com.moongeul.backend.api.post.dto.PostResponseDTO; import com.moongeul.backend.api.post.entity.Post; import com.moongeul.backend.api.category.repository.CategoryRepository; +import com.moongeul.backend.api.post.entity.Quote; import com.moongeul.backend.api.post.repository.PostRepository; +import com.moongeul.backend.api.post.repository.QuoteRepository; import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.exception.UnauthorizedException; import com.moongeul.backend.common.response.ErrorStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -26,25 +33,168 @@ public class PostService { private final BookRepository bookRepository; private final PostRepository postRepository; private final CategoryRepository categoryRepository; + private final QuoteRepository quoteRepository; /* 글쓰기 */ @Transactional - public PostCreateResponseDTO createPost(PostCreateRequestDTO postCreateRequestDTO, String email){ + public PostIdResponseDTO createPost(PostRequestDTO postRequestDTO, String email){ - Member member = memberRepository.findByEmail(email) - .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + Member member = getMemberByEmail(email); + Book book = getBook(postRequestDTO.getIsbn()); + Category category = getCategory(postRequestDTO.getCategoryId()); + + Post newPost = postRequestDTO.toEntity(category, member, book); + Post savedPost = postRepository.save(newPost); - Book book = bookRepository.findByIsbn(postCreateRequestDTO.getIsbn()) - .orElseThrow(() -> new NotFoundException(ErrorStatus.BOOK_NOTFOUND_EXCEPTION.getMessage())); + // Quote 저장 + saveQuotes(postRequestDTO, savedPost); - Category category = categoryRepository.findById(postCreateRequestDTO.getCategoryId()) - .orElseThrow(() -> new NotFoundException(ErrorStatus.CATEGORY_NOTFOUND_EXCEPTION.getMessage())); + return PostIdResponseDTO.builder() + .postId(savedPost.getId()) + .build(); + } - Post newPost = postCreateRequestDTO.toEntity(category, member, book); - Post savedPost = postRepository.save(newPost); + /* 기록(게시글) 상세 조회 */ + @Transactional + public PostResponseDTO getPostDetail(Long postId){ - return PostCreateResponseDTO.builder() - .postId(savedPost.getId()) + Post post = getPost(postId); + Book book = getBook(post.getBook().getIsbn()); + + // 책 정보(필요 정보만) DTO + PostResponseDTO.BookInfo bookInfo = PostResponseDTO.BookInfo.builder() + .isbn(book.getIsbn()) + .bookImage(book.getBookImage()) + .title(book.getTitle()) + .author(book.getAuthor()) + .publisher(book.getPublisher()) + .build(); + + // 인상깊은구절 조회 + List quotes = quoteRepository.findByPostId(postId); // 리스트 반환이기에 `orElseThrow()` 사용 x + List quoteDTOList = new ArrayList<>(); + for(Quote quote : quotes){ + PostResponseDTO.QuoteDTO quoteDTO = PostResponseDTO.QuoteDTO.builder() + .quoteContent(quote.getQuoteContent()) + .pageNumber(quote.getPageNumber()) + .build(); + quoteDTOList.add(quoteDTO); + } + + return PostResponseDTO.builder() + .bookInfo(bookInfo) + .rating(post.getRating()) + .content(post.getContent()) + .quotes(quoteDTOList) .build(); } + + /* 기록(게시글) 수정 */ + @Transactional + public PostIdResponseDTO updatePost(Long postId, String email, PostRequestDTO postRequestDTO){ + + Post post = getPost(postId); + Category category = getCategory(postRequestDTO.getCategoryId()); + Book book = getBook(post.getBook().getIsbn()); + + // 예외처리: 수정하는 사람과 게시글 주인이 같은지 확인 (본인의 게시글인지) + if (!post.getMember().getEmail().equals(email)) { + throw new UnauthorizedException(ErrorStatus.POST_UNAUTHORIZED.getMessage()); + } + + // 예외처리: 수정하는 사람과 카테고리 주인이 같은지 확인 (본인의 카테고리인지) + if (!category.getMember().getEmail().equals(email)) { + throw new UnauthorizedException(ErrorStatus.CATEGORY_UNAUTHORIZED.getMessage()); + } + + // 예외처리: 수정한 책 정보가 Book 테이블에 저장되어 있지 않은 경우 -> 저장 + // (책 검색 시 저장되기 때문에 우선 처리x, 대신 책에 대한 NOT_FOUND 에러 throw) + + + // (1) 기존 인상깊은구절 일괄 삭제 -> 전체 교체 방식 적용 + // 이유: 데이터가 많지 않으므로(최대 10개) 성능 이슈가 없고, 클라이언트 편의성 높이고 유지보수 간결 + quoteRepository.deleteAllByPostId(postId); + + // (2) 새로운 인상깊은구절 저장 + saveQuotes(postRequestDTO, post); + + // rating과 page가 null로 들어오면 기본값으로 대체 + Double finalRating = (postRequestDTO.getRating() != null) ? postRequestDTO.getRating() : 5.0; + Integer finalPage = (postRequestDTO.getPage() != null) ? postRequestDTO.getPage() : 300; + + // 기록(게시글) 내용 갱신 + post.update( + postRequestDTO.getReadDate(), + finalRating, + finalPage, + postRequestDTO.getContent(), + postRequestDTO.getPostVisibility(), + category, + book + ); + + return PostIdResponseDTO.builder() + .postId(post.getId()) + .build(); + } + + /* 기록(게시글) 삭제 */ + @Transactional + public void deletePost(Long postId, String email){ + + Post post = getPost(postId); + + // 예외처리: 수정하는 사람과 게시글 주인이 같은지 확인 (본인의 게시글인지) + if (!post.getMember().getEmail().equals(email)) { + throw new UnauthorizedException(ErrorStatus.POST_UNAUTHORIZED.getMessage()); + } + + // 인상깊은구절 일괄 삭제 + quoteRepository.deleteAllByPostId(postId); + + // 게시글 삭제 + postRepository.delete(post); + } + + + // 새로운 인상깊은구절 저장 + private void saveQuotes(PostRequestDTO postRequestDTO, Post post){ + + if (postRequestDTO.getQuotes() != null && !postRequestDTO.getQuotes().isEmpty()){ + for(PostRequestDTO.QuoteRequestDTO quoteRequestDTO : postRequestDTO.getQuotes()){ + Quote quote = Quote.builder() + .quoteContent(quoteRequestDTO.getQuoteContent()) + .pageNumber(quoteRequestDTO.getPageNumber()) + .post(post) + .build(); + + quoteRepository.save(quote); + } + } + } + + + /* + * 단순 데이터 불러오기용 코드 메서드 - 코드 깔끔하게 하기용 + */ + + private Member getMemberByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + } + + private Book getBook(String isbn) { + return bookRepository.findByIsbn(isbn) + .orElseThrow(() -> new NotFoundException(ErrorStatus.BOOK_NOTFOUND_EXCEPTION.getMessage())); + } + + private Post getPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.POST_NOTFOUND_EXCEPTION.getMessage())); + } + + private Category getCategory(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.CATEGORY_NOTFOUND_EXCEPTION.getMessage())); + } } diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index 5ab97d4..747953d 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -24,12 +24,15 @@ public enum ErrorStatus { */ AUTH_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "유효하지 않은 인가코드 입니다."), TOKEN_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었거나 유효하지 않은 토큰입니다."), + POST_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "수정하려는 회원의 게시글이 아닙니다."), + CATEGORY_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "수정하려는 회원의 카테고리가 아닙니다."), /** * 404 NOT_FOUND */ USER_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND,"해당 사용자를 찾을 수 없습니다."), BOOK_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 도서를 찾을 수 없습니다."), + POST_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 기록(게시글)을 찾을 수 없습니다."), CATEGORY_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 카테고리를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index c62bd5d..5eeee1f 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -28,6 +28,9 @@ public enum SuccessStatus { /* POST */ CREATE_POST_SUCCESS(HttpStatus.OK, "글쓰기 성공"), + GET_POST_SUCCESS(HttpStatus.OK, "기록(게시글) 상세 조회 성공"), + UPDATE_POST_SUCCESS(HttpStatus.OK, "기록(게시글) 수정 성공"), + DELETE_POST_SUCCESS(HttpStatus.OK, "기록(게시글) 삭제 성공"), /* CATEGORY */ CREATE_CATEGORY_SUCCESS(HttpStatus.OK, "카테고리 생성 성공"),