diff --git a/src/main/java/com/gdsc_knu/official_homepage/config/SecurityConfig.java b/src/main/java/com/gdsc_knu/official_homepage/config/SecurityConfig.java index bcc840bc..b1f18ab3 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/config/SecurityConfig.java +++ b/src/main/java/com/gdsc_knu/official_homepage/config/SecurityConfig.java @@ -31,7 +31,8 @@ public class SecurityConfig { private static final String[] WHITE_LIST = { "/api/post/{postId:\\d+}/comment/**", - "/api/post/trending" + "/api/post/trending", + "/api/post/**" }; private static final String[] MEMBER_AUTHENTICATION_LIST = { diff --git a/src/main/java/com/gdsc_knu/official_homepage/controller/post/PostController.java b/src/main/java/com/gdsc_knu/official_homepage/controller/post/PostController.java index f1c5b089..6e8bcd76 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/controller/post/PostController.java +++ b/src/main/java/com/gdsc_knu/official_homepage/controller/post/PostController.java @@ -1,28 +1,110 @@ package com.gdsc_knu.official_homepage.controller.post; -import com.gdsc_knu.official_homepage.dto.fileupload.FileUploadRequest; -import com.gdsc_knu.official_homepage.dto.fileupload.FileUploadResponse; +import com.gdsc_knu.official_homepage.annotation.TokenMember; +import com.gdsc_knu.official_homepage.authentication.jwt.JwtMemberDetail; +import com.gdsc_knu.official_homepage.dto.PagingResponse; +import com.gdsc_knu.official_homepage.dto.post.PostRequest; import com.gdsc_knu.official_homepage.dto.post.PostResponse; import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; +import com.gdsc_knu.official_homepage.dto.fileupload.FileUploadRequest; +import com.gdsc_knu.official_homepage.dto.fileupload.FileUploadResponse; import com.gdsc_knu.official_homepage.service.fileupload.FileUploader; + import com.gdsc_knu.official_homepage.service.post.PostService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; -@Tag(name = "Post", description = "테크블로그 관련 API") +@Tag(name = "Post", description = "테크블로그 게시글 관련 API") @RestController @RequestMapping("/api/post") @RequiredArgsConstructor public class PostController { private final PostService postService; private final FileUploader fileUploader; + + @GetMapping() + @Operation(summary = "게시글 목록 조회 API", description = "게시글 목록을 조회한다. 카테고리별로 조회할 수 있다. 없으면 전체 조회를 한다.") + public ResponseEntity> getPostList(@RequestParam(value = "category", required = false) Category category, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + PagingResponse postList = postService.getPostList(category, page, size); + return ResponseEntity.ok().body(postList); + } + + @GetMapping("/mypost") + @Operation(summary = "본인 게시글 목록 조회 API", description = "본인의 게시글 목록을 조회한다. 조회할 게시글 상태가 필요하다.") + public ResponseEntity> getTemporalPostList(@TokenMember JwtMemberDetail jwtMemberDetail, + @RequestParam(value = "status") PostStatus status, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + PagingResponse postList = postService.getTemporalPostList(jwtMemberDetail.getId(), status, page, size); + return ResponseEntity.ok().body(postList); + } + + @GetMapping("/{postId}") + @Operation(summary = "게시글 조회 API", description = "게시글 id로 게시글을 단건 조회한다.") + public ResponseEntity getPost(Authentication authentication, + @PathVariable("postId") Long postId) { + Long memberId = 0L; + if (authentication != null){ + JwtMemberDetail jwtMemberDetail = (JwtMemberDetail) authentication.getPrincipal(); + memberId = jwtMemberDetail.getId(); + } + + PostResponse.Detail post = postService.getPost(memberId, postId); + return ResponseEntity.ok().body(post); + } + +// @GetMapping("/{postId}/modify") +// @Operation(summary = "게시글 수정용 정보 조회 API", description = "게시글 수정을 위해 필요한 정보를 조회한다. 작성자만 조회 가능하다.") +// public ResponseEntity getTemporalPost(@PathVariable("postId") Long postId, +// @TokenMember JwtMemberDetail jwtMemberDetail) { +// PostResponse.Modify modifyPost = postService.getModifyPost(jwtMemberDetail.getId(), postId); +// return ResponseEntity.ok().body(modifyPost); +// } + @PostMapping() + @Operation(summary = "게시글 작성 API", description = "게시글을 작성한다. 회원만 작성 가능하다.") + public ResponseEntity createPost(@TokenMember JwtMemberDetail jwtMemberDetail, + @RequestBody PostRequest.Create postRequestDto) { + postService.createPost(jwtMemberDetail.getId(), postRequestDto); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PatchMapping("/{postId}") + @Operation(summary = "게시글 수정 API", description = "게시글을 수정한다. 작성자만 수정 가능하다.") + public ResponseEntity updatePost(@TokenMember JwtMemberDetail jwtMemberDetail, + @PathVariable("postId") Long postId, + @RequestBody PostRequest.Update postRequestDto) { + postService.updatePost(jwtMemberDetail.getId(), postId, postRequestDto); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{postId}") + @Operation(summary = "게시글 삭제 API", description = "게시글을 삭제한다. 작성자 혹은 관리자(Core)만 삭제 가능하다.") + public ResponseEntity deletePost(@TokenMember JwtMemberDetail jwtMemberDetail, + @PathVariable("postId") Long postId) { + postService.deletePost(jwtMemberDetail.getId(), postId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/search") + @Operation(summary = "게시글 검색 API", description = "제목, 부제목, 본문 내용에 키워드 포함 여부로 게시글을 검색한다.") + public ResponseEntity> searchPosts(@RequestParam(value = "keyword") String keyword, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + return ResponseEntity.ok().body(postService.searchPostList(keyword, page, size)); + } + @GetMapping("trending") @Operation(summary = "카테고리별 인기글 5개 조회", description = "category가 null이면 전제를 조회한다.") public ResponseEntity> getTrendingPosts( @@ -40,5 +122,4 @@ public ResponseEntity uploadImage(@Valid @ModelAttribute Fil String url = fileUploader.upload(request.getImage(), "post"); return ResponseEntity.ok().body(FileUploadResponse.of(url)); } - } diff --git a/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostRequest.java b/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostRequest.java new file mode 100644 index 00000000..f4410716 --- /dev/null +++ b/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostRequest.java @@ -0,0 +1,52 @@ +package com.gdsc_knu.official_homepage.dto.post; + +import com.gdsc_knu.official_homepage.entity.Member; +import com.gdsc_knu.official_homepage.entity.post.Post; +import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; + + +public class PostRequest { + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Create { + private String title; + private String content; + private String thumbnailUrl; + private Category category; + private PostStatus status; + + public static Post toEntity(Create create, Member member) { + LocalDateTime now = LocalDateTime.now(); + return Post.builder() + .title(create.getTitle()) + .content(create.getContent()) + .thumbnailUrl(create.getThumbnailUrl()) + .category(create.getCategory()) + .status(create.getStatus()) + .member(member) + .publishedAt(now) + .modifiedAt(now) + .build(); + } + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Update { + private String title; + private String content; + private String thumbnailUrl; + private Category category; + private PostStatus status; + } +} diff --git a/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostResponse.java b/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostResponse.java index 07a75cfe..20498bab 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostResponse.java +++ b/src/main/java/com/gdsc_knu/official_homepage/dto/post/PostResponse.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; import com.gdsc_knu.official_homepage.entity.post.Post; +import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,7 +19,7 @@ public class PostResponse { @Getter @Builder @AllArgsConstructor - public static class Main implements Serializable{ + public static class Main implements Serializable { private Long id; private String title; private String summary; @@ -29,20 +31,111 @@ public static class Main implements Serializable{ private LocalDateTime createAt; private int likeCount; private int commentCount; + private int sharedCount; public static Main from(Post post) { int length = Math.min(post.getContent().length(), 20); return Main.builder() .id(post.getId()) .title(post.getTitle()) - .summary(post.getContent().substring(0,length)) + .summary(post.getContent().substring(0, length)) .thumbnailUrl(post.getThumbnailUrl()) .category(post.getCategory().name()) .createAt(post.getPublishedAt()) .likeCount(post.getLikeCount()) - .commentCount(post.getCommentCount()) + .commentCount(post.getCommentList().size()) + .sharedCount(post.getSharedCount()) .build(); } } + + @Getter + @Builder + @AllArgsConstructor + public static class Detail { + private Long id; + private String title; + private String summary; + private String thumbnailUrl; + private String category; + private String content; + private String authorName; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime createAt; + private int likeCount; + private int commentCount; + private int sharedCount; + private boolean canDelete; + private boolean canModify; + + public static Detail from(Post post, AccessModel access) { + int length = Math.min(post.getContent().length(), 20); + return Detail.builder() + .id(post.getId()) + .title(post.getTitle()) + .summary(post.getContent().substring(0, length)) + .thumbnailUrl(post.getThumbnailUrl()) + .category(post.getCategory().name()) + .content(post.getContent()) + .authorName(post.getMember().getName()) + .createAt(post.getPublishedAt()) + .likeCount(post.getLikeCount()) + .commentCount(post.getCommentList().size()) + .sharedCount(post.getSharedCount()) + .canDelete(access.canDelete()) + .canModify(access.canModify()) + .build(); + + } + } + + @Getter + @Builder + @AllArgsConstructor + public static class Temp { + private Long id; + private String title; + private String summary; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime createAt; + + public static Temp from(Post post) { + int length = Math.min(post.getContent().length(), 20); + return Temp.builder() + .id(post.getId()) + .title(post.getTitle()) + .summary(post.getContent().substring(0, length)) + .createAt(post.getPublishedAt()) + .build(); + } + } + +// @Getter +// @Builder +// @AllArgsConstructor +// public static class Modify { +// private String title; +// private String content; +// private String summary; +// private String thumbnailUrl; +// private Category category; +// private PostStatus status; +// +// public static Modify from(Post post) { +// int length = Math.min(post.getContent().length(), 20); +// return Modify.builder() +// .title(post.getTitle()) +// .summary(post.getContent().substring(0, length)) +// .content(post.getContent()) +// .thumbnailUrl(post.getThumbnailUrl()) +// .category(post.getCategory()) +// .status(post.getStatus()) +// .build(); +// } +// } } diff --git a/src/main/java/com/gdsc_knu/official_homepage/entity/post/Post.java b/src/main/java/com/gdsc_knu/official_homepage/entity/post/Post.java index 4b549b14..55817572 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/entity/post/Post.java +++ b/src/main/java/com/gdsc_knu/official_homepage/entity/post/Post.java @@ -1,5 +1,6 @@ package com.gdsc_knu.official_homepage.entity.post; +import com.gdsc_knu.official_homepage.dto.post.PostRequest; import com.gdsc_knu.official_homepage.entity.Member; import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; @@ -47,4 +48,16 @@ public class Post { private LocalDateTime publishedAt; private LocalDateTime modifiedAt; + public void update(PostRequest.Update postRequest) { + this.title = postRequest.getTitle(); + this.content = postRequest.getContent(); + this.thumbnailUrl = postRequest.getThumbnailUrl(); + this.category = postRequest.getCategory(); + this.status = postRequest.getStatus(); + this.modifiedAt = LocalDateTime.now(); + } + + public boolean isSaved() { + return this.status.equals(PostStatus.SAVED); + } } diff --git a/src/main/java/com/gdsc_knu/official_homepage/exception/ErrorCode.java b/src/main/java/com/gdsc_knu/official_homepage/exception/ErrorCode.java index e5abaeda..4ef183a3 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/exception/ErrorCode.java +++ b/src/main/java/com/gdsc_knu/official_homepage/exception/ErrorCode.java @@ -35,6 +35,7 @@ public enum ErrorCode { // Post POST_NOT_FOUND(404, HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), + POST_FORBIDDEN(403, HttpStatus.FORBIDDEN, "게시글을 수정할 수 있는 권한이 없습니다."), // Comment COMMENT_NOT_FOUND(404, HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다."), diff --git a/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactory.java b/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactory.java index ea745071..928598bd 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactory.java +++ b/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactory.java @@ -2,9 +2,15 @@ import com.gdsc_knu.official_homepage.entity.post.Post; import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; public interface PostQueryFactory { List findTop5ByCategory(Category category, int size); + + Page findAllByCategory(Pageable pageable, Category category); + + Page searchByKeyword(Pageable pageable, String keyword); } diff --git a/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactoryImpl.java b/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactoryImpl.java index f6b71b23..75c56ea6 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactoryImpl.java +++ b/src/main/java/com/gdsc_knu/official_homepage/repository/PostQueryFactoryImpl.java @@ -7,6 +7,9 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.List; @@ -26,6 +29,48 @@ public List findTop5ByCategory(Category category, int size) { .fetch(); } + @Override + public Page findAllByCategory(Pageable pageable, Category category) { + List postList = jpaQueryFactory + .selectFrom(QPost.post) + .where(QPost.post.status.eq(PostStatus.SAVED) + .and(eqCategory(category))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(QPost.post.count()) + .from(QPost.post) + .where(QPost.post.status.eq(PostStatus.SAVED) + .and(eqCategory(category))) + .fetchFirst(); + + return new PageImpl<>(postList, pageable, total == null ? 0 : total); + } + + @Override + public Page searchByKeyword(Pageable pageable, String keyword) { + List postList = jpaQueryFactory + .selectFrom(QPost.post) + .where(QPost.post.status.eq(PostStatus.SAVED) + .and(QPost.post.title.contains(keyword) + .or(QPost.post.content.contains(keyword)))) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = jpaQueryFactory + .select(QPost.post.count()) + .from(QPost.post) + .where(QPost.post.status.eq(PostStatus.SAVED) + .and(QPost.post.title.contains(keyword) + .or(QPost.post.content.contains(keyword)))) + .fetchFirst(); + + return new PageImpl<>(postList, pageable, total == null ? 0 : total); + } + private BooleanExpression eqCategory(Category category) { return category == null ? null : QPost.post.category.eq(category); } diff --git a/src/main/java/com/gdsc_knu/official_homepage/repository/PostRepository.java b/src/main/java/com/gdsc_knu/official_homepage/repository/PostRepository.java index 0b3f36c2..ef952634 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/repository/PostRepository.java +++ b/src/main/java/com/gdsc_knu/official_homepage/repository/PostRepository.java @@ -1,7 +1,13 @@ package com.gdsc_knu.official_homepage.repository; import com.gdsc_knu.official_homepage.entity.post.Post; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface PostRepository extends JpaRepository, PostQueryFactory { + Page findAllByMemberIdAndStatus(Long memberId, PostStatus status, Pageable pageable); } diff --git a/src/main/java/com/gdsc_knu/official_homepage/service/post/PostService.java b/src/main/java/com/gdsc_knu/official_homepage/service/post/PostService.java index d35659c2..fa110af5 100644 --- a/src/main/java/com/gdsc_knu/official_homepage/service/post/PostService.java +++ b/src/main/java/com/gdsc_knu/official_homepage/service/post/PostService.java @@ -1,40 +1,29 @@ package com.gdsc_knu.official_homepage.service.post; +import com.gdsc_knu.official_homepage.dto.PagingResponse; +import com.gdsc_knu.official_homepage.dto.post.PostRequest; import com.gdsc_knu.official_homepage.dto.post.PostResponse; -import com.gdsc_knu.official_homepage.entity.post.Post; import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; -import com.gdsc_knu.official_homepage.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; import java.util.List; +public interface PostService { + void createPost(Long memberId, PostRequest.Create postRequest); -@Slf4j -@Service -@RequiredArgsConstructor -public class PostService { - private final PostRepository postRepository; - - @Transactional(readOnly = true) - @Cacheable(value = "trending-post", key = "'Is '+#category", unless="#result.size()<5") - public List getTrendingPosts(Category category, int size) { - List posts = postRepository.findTop5ByCategory(category, size); - return posts.stream().map(PostResponse.Main::from).toList(); - } - - @Scheduled(cron = "0 0 0 * * ?") - public void invokeClearPost() { - clearTrendingPosts(); - } - - @CacheEvict(value = "trending-post", allEntries = true, beforeInvocation = true) - public void clearTrendingPosts() { - log.info("trending post 초기화"); - } + PostResponse.Detail getPost(Long memberId, Long postId); + + PagingResponse getPostList(Category category, int page, int size); + + PagingResponse getTemporalPostList(Long memberId, PostStatus status, int page, int size); + +// PostResponse.Modify getModifyPost(Long memberId, Long postId); + + void updatePost(Long memberId, Long postId, PostRequest.Update postRequest); + + void deletePost(Long memberId, Long postId); + + PagingResponse searchPostList(String keyword, int page, int size); + + List getTrendingPosts(Category category, int size); } diff --git a/src/main/java/com/gdsc_knu/official_homepage/service/post/PostServiceImpl.java b/src/main/java/com/gdsc_knu/official_homepage/service/post/PostServiceImpl.java new file mode 100644 index 00000000..607f57b0 --- /dev/null +++ b/src/main/java/com/gdsc_knu/official_homepage/service/post/PostServiceImpl.java @@ -0,0 +1,170 @@ +package com.gdsc_knu.official_homepage.service.post; + +import com.gdsc_knu.official_homepage.dto.PagingResponse; +import com.gdsc_knu.official_homepage.dto.post.AccessModel; +import com.gdsc_knu.official_homepage.dto.post.PostRequest; +import com.gdsc_knu.official_homepage.dto.post.PostResponse; +import com.gdsc_knu.official_homepage.entity.Member; +import com.gdsc_knu.official_homepage.entity.enumeration.Role; +import com.gdsc_knu.official_homepage.entity.post.Post; +import com.gdsc_knu.official_homepage.entity.post.enumeration.Category; +import com.gdsc_knu.official_homepage.entity.post.enumeration.PostStatus; +import com.gdsc_knu.official_homepage.exception.CustomException; +import com.gdsc_knu.official_homepage.exception.ErrorCode; +import com.gdsc_knu.official_homepage.repository.MemberRepository; +import com.gdsc_knu.official_homepage.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class PostServiceImpl implements PostService { + private final PostRepository postRepository; + private final MemberRepository memberRepository; + + /** + * 게시글 작성, 회원만 작성 가능 + * @param memberId 회원 id + * @param postRequest 게시글 작성 요청 DTO + * @throws CustomException ErrorCode.USER_NOT_FOUND + */ + @Override + @Transactional + public void createPost(Long memberId, PostRequest.Create postRequest) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + Post post = PostRequest.Create.toEntity(postRequest, member); + postRepository.save(post); + } + + /** + * 게시글 조회, 저장(SAVED)된 게시글만 조회 가능 + * @param postId 게시글 id + * @return PostResponse.Detail + * @throws CustomException ErrorCode.POST_NOT_FOUND + */ + @Override + @Transactional(readOnly = true) + public PostResponse.Detail getPost(Long memberId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + if (!post.isSaved()) { + throw new CustomException(ErrorCode.POST_NOT_FOUND); + } + AccessModel access = getPostAccess(memberId, post.getMember().getId()); + return PostResponse.Detail.from(post, access); + } + + /** + * 카테고리별 게시글 목록 조회, 저장(SAVED)된 게시글만 조회 가능 + * @param category 카테고리, null이면 전체 조회 + * @return List + */ + @Override + @Transactional(readOnly = true) + public PagingResponse getPostList(Category category, int page, int size) { + Page postPage = postRepository.findAllByCategory(PageRequest.of(page, size), category); + return PagingResponse.withoutCountFrom(postPage, size, PostResponse.Main::from); + } + + @Override + @Transactional(readOnly = true) + public PagingResponse getTemporalPostList(Long memberId, PostStatus status, int page, int size) { + Page postList = postRepository.findAllByMemberIdAndStatus(memberId, status, PageRequest.of(page, size)); + return PagingResponse.withoutCountFrom(postList, size, PostResponse.Temp::from); + } + +// @Override +// @Transactional(readOnly = true) +// public PostResponse.Modify getModifyPost(Long memberId, Long postId) { +// Post post = postRepository.findById(postId) +// .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); +// if (!post.getMember().getId().equals(memberId)) { +// throw new CustomException(ErrorCode.POST_FORBIDDEN); +// } +// PostResponse.Modify modifyPost = PostResponse.Modify.from(post); +// +// return modifyPost; +// } + + @Override + @Transactional(readOnly = true) + public PagingResponse searchPostList(String keyword, int page, int size) { + Page postPage = postRepository.searchByKeyword(PageRequest.of(page, size), keyword); + return PagingResponse.withoutCountFrom(postPage, size, PostResponse.Main::from); + } + + /** + * 게시글 수정, 작성자만 수정 가능 + * @param memberId 회원 id + * @param postId 게시글 id + * @param postRequest 게시글 수정 요청 DTO + * @throws CustomException ErrorCode.POST_NOT_FOUND, ErrorCode.POST_FORBIDDEN + */ + @Override + @Transactional + public void updatePost(Long memberId, Long postId, PostRequest.Update postRequest) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + if (!post.getMember().getId().equals(memberId)) { + throw new CustomException(ErrorCode.POST_FORBIDDEN); + } + post.update(postRequest); + } + + /** + * 게시글 삭제, 작성자 혹은 관리자(Core)만 삭제 가능 + * @param memberId 회원 id + * @param postId 게시글 id + * @throws CustomException ErrorCode.POST_NOT_FOUND, ErrorCode.USER_NOT_FOUND, ErrorCode.POST_FORBIDDEN + */ + @Override + @Transactional + public void deletePost(Long memberId, Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND)); + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + if (!post.getMember().getId().equals(memberId) && !member.getRole().equals(Role.ROLE_CORE)) { + // 코어는 게시글 삭제도 가능 + throw new CustomException(ErrorCode.POST_FORBIDDEN); + } + postRepository.delete(post); + } + + @Override + @Transactional(readOnly = true) + @Cacheable(value = "trending-post", key = "'Is '+#category", unless="#result.size()<5") + public List getTrendingPosts(Category category, int size) { + List posts = postRepository.findTop5ByCategory(category, size); + return posts.stream().map(PostResponse.Main::from).toList(); + } + + @Scheduled(cron = "0 0 0 * * ?") + public void invokeClearPost() { + clearTrendingPosts(); + } + + @CacheEvict(value = "trending-post", allEntries = true, beforeInvocation = true) + public void clearTrendingPosts() { + log.info("trending post 초기화"); + } + + private AccessModel getPostAccess(Long memberId, Long postAuthorId) { + boolean canModify = memberId.equals(postAuthorId); + boolean canDelete = memberId.equals(postAuthorId) || memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)) + .getRole().equals(Role.ROLE_CORE); + return AccessModel.of(canDelete, canModify); + } +}