diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 4ac823e..43420cc 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -161,6 +161,40 @@ include::{snippetsDir}/updatePost/11/http-response.adoc[] include::{snippetsDir}/updatePost/12/http-response.adoc[] +--- + +=== **4. 게시글 삭제** + +유저픽 게시글 삭제 api 입니다. + +`Soft Delete` 전략을 사용해 `isDeleted` , `deletedAt` 필드를 업데이트함으로써 실제 데이터를 물리적으로 삭제하지 않고 삭제된 것으로 간주하여 처리했습니다. + +스케쥴러를 통해 매일 오전 6시에 전체 게시글을 조회하면서 삭제 처리된지 30일이 지난 데이터는 `Hard Delete` 를 수행합니다. + +==== Request +include::{snippetsDir}/deletePost/1/http-request.adoc[] + +==== Request Path Parameters +include::{snippetsDir}/deletePost/1/path-parameters.adoc[] + +==== 성공 Response +include::{snippetsDir}/deletePost/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/deletePost/1/response-fields.adoc[] + +==== 실패 Response +실패 1. 인증되지 않은 유저일 경우 + +include::{snippetsDir}/deletePost/2/http-response.adoc[] + +실패 2. 존재하지 않는 게시글 ID일 경우 + +include::{snippetsDir}/deletePost/3/http-response.adoc[] + +실패 3. 게시글을 삭제할 권한이 없을 경우 (해당 게시글의 작성자가 아닐 경우) + +include::{snippetsDir}/deletePost/4/http-response.adoc[] + + diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/DeletePostController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/DeletePostController.java new file mode 100644 index 0000000..87c0328 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/DeletePostController.java @@ -0,0 +1,30 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.application.command.post.DeletePostCommand; +import com.ftm.server.application.port.in.post.DeletePostUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class DeletePostController { + + private final DeletePostUseCase deletePostUseCase; + + @DeleteMapping("/api/posts/{postId}") + public ResponseEntity> deletePost( + @PathVariable Long postId, @AuthenticationPrincipal UserPrincipal userPrincipal) { + + deletePostUseCase.execute(DeletePostCommand.of(postId, userPrincipal.getId())); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessResponseCode.OK)); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java index e7b99ec..6543ba2 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java @@ -32,6 +32,7 @@ public class PostDomainPersistenceAdapter UpdatePostPort, UpdatePostProductPort, UpdatePostProductImagePort, + DeletePostPort, DeletePostImagePort, DeletePostProductPort, DeletePostProductImagePort { @@ -139,6 +140,13 @@ public Optional loadPost(FindByIdQuery query) { return postRepository.findById(query.getId()).map(postMapper::toDomainEntity); } + @Override + public List loadPostsByDeleteOption(FindPostByDeleteOptionQuery query) { + return postRepository.findAllByDeletedBefore(query).stream() + .map(postMapper::toDomainEntity) + .toList(); + } + @Override public List loadPostImagesByPostId(FindByPostIdQuery query) { PostJpaEntity postJpaEntity = @@ -151,6 +159,13 @@ public List loadPostImagesByPostId(FindByPostIdQuery query) { .toList(); } + @Override + public List loadPostImagesByPostIds(FindByIdsQuery query) { + return postImageRepository.findAllByPostIdIn(query.getIds()).stream() + .map(postImageMapper::toDomainEntity) + .toList(); + } + @Override public List loadPostProductsByPostId(FindByPostIdQuery query) { PostJpaEntity postJpaEntity = @@ -170,10 +185,17 @@ public List loadPostProductsByIds(FindByIdsQuery query) { .toList(); } + @Override + public List loadPostProductsByPostIds(FindByIdsQuery query) { + return postProductRepository.findAllByPostIdIn(query.getIds()).stream() + .map(postProductMapper::toDomainEntity) + .toList(); + } + @Override public List loadPostProductImagesByPostProductIds(FindByIdsQuery query) { List postProductImageJpaEntities = - postProductImageRepository.findByPostProductIds(query); + postProductImageRepository.findAllByPostProductIdIn(query.getIds()); return postProductImageJpaEntities.stream() .map(postProductImageMapper::toDomainEntity) @@ -238,23 +260,31 @@ public void updatePostProductImages(List postProductImages) { } } + @Override + public void deletePostsByIds(List postIds) { + postRepository.deleteAllByIdInBatch(postIds); + } + + @Override public void deletePostImages(List postImages) { List ids = postImages.stream().map(PostImage::getId).toList(); - - postImageRepository.deleteAllById(ids); + postImageRepository.deleteAllByIdInBatch(ids); } @Override public void deletePostProducts(List postProducts) { List ids = postProducts.stream().map(PostProduct::getId).toList(); + postProductRepository.deleteAllByIdInBatch(ids); + } - postProductRepository.deleteAllById(ids); + @Override + public void deletePostProductsByIds(List postProductIds) { + postProductRepository.deleteAllByIdInBatch(postProductIds); } @Override public void deletePostProductImages(List postProductImages) { List ids = postProductImages.stream().map(PostProductImage::getId).toList(); - - postProductImageRepository.deleteAllById(ids); + postProductImageRepository.deleteAllByIdInBatch(ids); } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java new file mode 100644 index 0000000..4d8e6e5 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepository.java @@ -0,0 +1,10 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; +import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import java.util.List; + +public interface PostCustomRepository { + + List findAllByDeletedBefore(FindPostByDeleteOptionQuery query); +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java new file mode 100644 index 0000000..693fcee --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostCustomRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.ftm.server.adapter.out.persistence.repository; + +import static com.ftm.server.adapter.out.persistence.model.QPostJpaEntity.postJpaEntity; + +import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; +import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PostCustomRepositoryImpl implements PostCustomRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllByDeletedBefore(FindPostByDeleteOptionQuery query) { + return queryFactory + .selectFrom(postJpaEntity) + .where( + postJpaEntity.isDeleted.eq(query.getIsDeleted()), + postJpaEntity.deletedAt.loe(query.getDeletedAt().atTime(LocalTime.MAX))) + .fetch(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java index d6b2292..392527b 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostImageRepository.java @@ -2,10 +2,19 @@ import com.ftm.server.adapter.out.persistence.model.PostImageJpaEntity; import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; +import io.lettuce.core.dynamic.annotation.Param; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; public interface PostImageRepository extends JpaRepository { List findAllByPost(PostJpaEntity post); + + List findAllByPostIdIn(List postIds); + + @Modifying + @Query("DELETE FROM PostImageJpaEntity pi WHERE pi.id IN (:postImageIds)") + void deleteAllByIdInBatch(@Param("postImageIds") List postImageIds); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepository.java deleted file mode 100644 index 22296f3..0000000 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ftm.server.adapter.out.persistence.repository; - -import com.ftm.server.adapter.out.persistence.model.PostProductImageJpaEntity; -import com.ftm.server.application.query.FindByIdsQuery; -import java.util.List; - -public interface PostProductImageCustomRepository { - - List findByPostProductIds(FindByIdsQuery query); -} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepositoryImpl.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepositoryImpl.java deleted file mode 100644 index f40bcc5..0000000 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageCustomRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ftm.server.adapter.out.persistence.repository; - -import static com.ftm.server.adapter.out.persistence.model.QPostProductImageJpaEntity.postProductImageJpaEntity; - -import com.ftm.server.adapter.out.persistence.model.PostProductImageJpaEntity; -import com.ftm.server.application.query.FindByIdsQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class PostProductImageCustomRepositoryImpl implements PostProductImageCustomRepository { - - private final JPAQueryFactory queryFactory; - - @Override - public List findByPostProductIds(FindByIdsQuery query) { - return queryFactory - .selectFrom(postProductImageJpaEntity) - .where(postProductImageJpaEntity.postProduct.id.in(query.getIds())) - .fetch(); - } -} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageRepository.java index 86a9a40..64515a8 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductImageRepository.java @@ -1,7 +1,17 @@ package com.ftm.server.adapter.out.persistence.repository; import com.ftm.server.adapter.out.persistence.model.PostProductImageJpaEntity; +import io.lettuce.core.dynamic.annotation.Param; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; -public interface PostProductImageRepository - extends JpaRepository, PostProductImageCustomRepository {} +public interface PostProductImageRepository extends JpaRepository { + + List findAllByPostProductIdIn(List postProductIds); + + @Modifying + @Query("DELETE FROM PostProductImageJpaEntity ppi WHERE ppi.id IN (:postProductImageIds)") + void deleteAllByIdInBatch(@Param("postProductImageIds") List postProductImageIds); +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java index 4f6f9a8..aff7e50 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostProductRepository.java @@ -2,10 +2,19 @@ import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; import com.ftm.server.adapter.out.persistence.model.PostProductJpaEntity; +import io.lettuce.core.dynamic.annotation.Param; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; public interface PostProductRepository extends JpaRepository { List findAllByPost(PostJpaEntity post); + + List findAllByPostIdIn(List postIds); + + @Modifying + @Query("DELETE FROM PostProductJpaEntity pp WHERE pp.id IN (:postProductIds)") + void deleteAllByIdInBatch(@Param("postProductIds") List postProductIds); } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java index fa48214..25ee8f4 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/repository/PostRepository.java @@ -3,8 +3,15 @@ import com.ftm.server.adapter.out.persistence.model.PostJpaEntity; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface PostRepository extends JpaRepository { +public interface PostRepository extends JpaRepository, PostCustomRepository { List findByUserId(Long userId); + + @Modifying + @Query("DELETE FROM PostJpaEntity p WHERE p.id IN (:postIds)") + void deleteAllByIdInBatch(@Param("postIds") List postIds); } diff --git a/src/main/java/com/ftm/server/adapter/out/scheduler/PostHardDeleteScheduler.java b/src/main/java/com/ftm/server/adapter/out/scheduler/PostHardDeleteScheduler.java new file mode 100644 index 0000000..c0088aa --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/scheduler/PostHardDeleteScheduler.java @@ -0,0 +1,27 @@ +package com.ftm.server.adapter.out.scheduler; + +import com.ftm.server.application.port.in.post.PostHardDeleteUseCase; +import com.ftm.server.common.annotation.Adapter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; + +@Slf4j +@Adapter +@RequiredArgsConstructor +public class PostHardDeleteScheduler { + + private final PostHardDeleteUseCase postHardDeleteUseCase; + + // 매일 오전 3시 hard delete 진행 + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void run() { + log.info( + "Posts Hard Delete started at {}", + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + postHardDeleteUseCase.execute(); + log.info("Posts Hard Deleted Finish."); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/scheduler/UserHardDeleteScheduler.java b/src/main/java/com/ftm/server/adapter/out/scheduler/UserHardDeleteScheduler.java index 1014d1f..2bf94af 100644 --- a/src/main/java/com/ftm/server/adapter/out/scheduler/UserHardDeleteScheduler.java +++ b/src/main/java/com/ftm/server/adapter/out/scheduler/UserHardDeleteScheduler.java @@ -2,9 +2,13 @@ import com.ftm.server.application.port.in.user.UserHardDeleteUseCase; import com.ftm.server.common.annotation.Adapter; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; +@Slf4j @Adapter @RequiredArgsConstructor public class UserHardDeleteScheduler { @@ -12,8 +16,12 @@ public class UserHardDeleteScheduler { private final UserHardDeleteUseCase userHardDeleteUseCase; // 매일 새벽 3시 hard delete 진행 - @Scheduled(cron = "0 0 3 * * *") + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") public void run() { + log.info( + "Users Hard Delete started at {}", + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); userHardDeleteUseCase.execute(); + log.info("Users Hard Deleted Finish."); } } diff --git a/src/main/java/com/ftm/server/application/command/post/DeletePostCommand.java b/src/main/java/com/ftm/server/application/command/post/DeletePostCommand.java new file mode 100644 index 0000000..742cbb1 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/DeletePostCommand.java @@ -0,0 +1,19 @@ +package com.ftm.server.application.command.post; + +import lombok.Getter; + +@Getter +public class DeletePostCommand { + + private final Long postId; + private final Long userId; + + private DeletePostCommand(Long postId, Long userId) { + this.postId = postId; + this.userId = userId; + } + + public static DeletePostCommand of(Long postId, Long userId) { + return new DeletePostCommand(postId, userId); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/DeletePostUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/DeletePostUseCase.java new file mode 100644 index 0000000..b801021 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/DeletePostUseCase.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.application.command.post.DeletePostCommand; +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface DeletePostUseCase { + + void execute(DeletePostCommand command); +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/PostHardDeleteUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/PostHardDeleteUseCase.java new file mode 100644 index 0000000..74ebff6 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/PostHardDeleteUseCase.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface PostHardDeleteUseCase { + + void execute(); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostPort.java new file mode 100644 index 0000000..6511191 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostPort.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import java.util.List; + +@Port +public interface DeletePostPort { + + void deletePostsByIds(List postIds); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java index 9f14dfc..05354c3 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java @@ -8,4 +8,6 @@ public interface DeletePostProductPort { void deletePostProducts(List postProducts); + + void deletePostProductsByIds(List postProductIds); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostImagePort.java index 9f1a713..3c93ee7 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostImagePort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostImagePort.java @@ -1,5 +1,6 @@ package com.ftm.server.application.port.out.persistence.post; +import com.ftm.server.application.query.FindByIdsQuery; import com.ftm.server.application.query.FindByPostIdQuery; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.PostImage; @@ -9,4 +10,6 @@ public interface LoadPostImagePort { List loadPostImagesByPostId(FindByPostIdQuery query); + + List loadPostImagesByPostIds(FindByIdsQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java index 21539ce..9f219af 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostPort.java @@ -1,12 +1,16 @@ package com.ftm.server.application.port.out.persistence.post; import com.ftm.server.application.query.FindByIdQuery; +import com.ftm.server.application.query.FindPostByDeleteOptionQuery; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.Post; +import java.util.List; import java.util.Optional; @Port public interface LoadPostPort { Optional loadPost(FindByIdQuery query); + + List loadPostsByDeleteOption(FindPostByDeleteOptionQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java index adba404..a7930e1 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductPort.java @@ -12,4 +12,6 @@ public interface LoadPostProductPort { List loadPostProductsByPostId(FindByPostIdQuery query); List loadPostProductsByIds(FindByIdsQuery query); + + List loadPostProductsByPostIds(FindByIdsQuery query); } diff --git a/src/main/java/com/ftm/server/application/query/FindPostByDeleteOptionQuery.java b/src/main/java/com/ftm/server/application/query/FindPostByDeleteOptionQuery.java new file mode 100644 index 0000000..515b900 --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindPostByDeleteOptionQuery.java @@ -0,0 +1,20 @@ +package com.ftm.server.application.query; + +import java.time.LocalDate; +import lombok.Getter; + +@Getter +public class FindPostByDeleteOptionQuery { + + private final Boolean isDeleted; + private final LocalDate deletedAt; + + private FindPostByDeleteOptionQuery(Boolean isDeleted, LocalDate deletedAt) { + this.isDeleted = isDeleted; + this.deletedAt = deletedAt; + } + + public static FindPostByDeleteOptionQuery of(Boolean isDeleted, LocalDate deletedAt) { + return new FindPostByDeleteOptionQuery(isDeleted, deletedAt); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/DeletePostService.java b/src/main/java/com/ftm/server/application/service/post/DeletePostService.java new file mode 100644 index 0000000..2d5233c --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/DeletePostService.java @@ -0,0 +1,38 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.command.post.DeletePostCommand; +import com.ftm.server.application.port.in.post.DeletePostUseCase; +import com.ftm.server.application.port.out.persistence.post.LoadPostPort; +import com.ftm.server.application.port.out.persistence.post.UpdatePostPort; +import com.ftm.server.application.query.FindByIdQuery; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Post; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DeletePostService implements DeletePostUseCase { + + private final LoadPostPort loadPostPort; + private final UpdatePostPort updatePostPort; + + @Override + @Transactional + public void execute(DeletePostCommand command) { + Post post = + loadPostPort + .loadPost(FindByIdQuery.of(command.getPostId())) + .orElseThrow(() -> new CustomException(ErrorResponseCode.POST_NOT_FOUND)); + post.validateWriter(command.getUserId()); + + // 게시글 삭제 필드 업데이트 + post.updateIsDeleted(true); + post.updateDeletedAt(LocalDateTime.now()); + + updatePostPort.updatePost(post); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/PostHardDeleteService.java b/src/main/java/com/ftm/server/application/service/post/PostHardDeleteService.java new file mode 100644 index 0000000..9720a4f --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/PostHardDeleteService.java @@ -0,0 +1,84 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.port.in.post.PostHardDeleteUseCase; +import com.ftm.server.application.port.out.persistence.post.*; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.transcation.AfterCommitExecutorPort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.query.FindPostByDeleteOptionQuery; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostImage; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.PostProductImage; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostHardDeleteService implements PostHardDeleteUseCase { + + private final LoadPostPort loadPostPort; + private final LoadPostImagePort loadPostImagePort; + private final LoadPostProductPort loadPostProductPort; + private final LoadPostProductImagePort loadPostProductImagePort; + private final DeletePostPort deletePostPort; + private final DeletePostImagePort deletePostImagePort; + private final DeletePostProductPort deletePostProductPort; + private final DeletePostProductImagePort deletePostProductImagePort; + + private final S3ImageDeletePort s3ImageDeletePort; + private final AfterCommitExecutorPort afterCommitExecutorPort; + + @Override + @Transactional + public void execute() { + // 삭제 대상 post 조회 : 이미 삭제가 된 게시글 중 30일이 지난 경우 hard delete 대상 + List deletedPostIds = + loadPostPort + .loadPostsByDeleteOption( + FindPostByDeleteOptionQuery.of(true, LocalDate.now().minusDays(30))) + .stream() + .map(Post::getId) + .toList(); + + // 삭제할 post product 조회 + List deletedPostProductIds = + loadPostProductPort + .loadPostProductsByPostIds(FindByIdsQuery.from(deletedPostIds)) + .stream() + .map(PostProduct::getId) + .toList(); + + // post 관련 엔티티 모두 삭제 + // 1. 상품 이미지 삭제 + List postProductImages = + loadPostProductImagePort.loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(deletedPostProductIds)); + List deleteProductImageObjectKeys = + postProductImages.stream().map(PostProductImage::getObjectKey).toList(); + registerCommitHook(deleteProductImageObjectKeys); + deletePostProductImagePort.deletePostProductImages(postProductImages); + + // 2. 상품 삭제 + deletePostProductPort.deletePostProductsByIds(deletedPostProductIds); + + // 3. 게시글 이미지 삭제 + List postImages = + loadPostImagePort.loadPostImagesByPostIds(FindByIdsQuery.from(deletedPostIds)); + List deletePostImageObjectKeys = + postImages.stream().map(PostImage::getObjectKey).toList(); + registerCommitHook(deletePostImageObjectKeys); + deletePostImagePort.deletePostImages(postImages); + + // 4. 게시글 삭제 + deletePostPort.deletePostsByIds(deletedPostIds); + } + + private void registerCommitHook(List deleteImageObjectKeys) { + afterCommitExecutorPort.doAfterCommit( + () -> s3ImageDeletePort.deleteImages(deleteImageObjectKeys)); + } +} diff --git a/src/main/java/com/ftm/server/domain/entity/Post.java b/src/main/java/com/ftm/server/domain/entity/Post.java index c659ed3..b44193d 100644 --- a/src/main/java/com/ftm/server/domain/entity/Post.java +++ b/src/main/java/com/ftm/server/domain/entity/Post.java @@ -98,10 +98,6 @@ public static Post create(SavePostCommand command) { .build(); } - public void updateViewCount(int viewCount) { - this.viewCount = viewCount; - } - public void update(UpdatePostCommand command) { if (command.getTitle() != null) this.title = command.getTitle(); if (command.getContent() != null) this.content = command.getContent(); @@ -110,10 +106,20 @@ public void update(UpdatePostCommand command) { if (command.getHashTags() != null) this.hashtags = command.getHashTags(); } - public void validateDeleted() { - if (this.isDeleted && this.deletedAt != null) { - throw new CustomException(ErrorResponseCode.POST_NOT_FOUND); - } + public void updateUserId(Long userId) { + this.userId = userId; + } + + public void updateViewCount(int viewCount) { + this.viewCount = viewCount; + } + + public void updateIsDeleted(Boolean isDeleted) { + this.isDeleted = isDeleted; + } + + public void updateDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; } public void validateWriter(Long userId) { @@ -122,7 +128,9 @@ public void validateWriter(Long userId) { } } - public void updateUserId(Long userId) { - this.userId = userId; + public void validateDeleted() { + if (this.isDeleted && this.deletedAt != null) { + throw new CustomException(ErrorResponseCode.POST_NOT_FOUND); + } } } diff --git a/src/main/java/com/ftm/server/infrastructure/scheduler/SchedulerConfig.java b/src/main/java/com/ftm/server/infrastructure/scheduler/SchedulerConfig.java new file mode 100644 index 0000000..91f4a40 --- /dev/null +++ b/src/main/java/com/ftm/server/infrastructure/scheduler/SchedulerConfig.java @@ -0,0 +1,26 @@ +package com.ftm.server.infrastructure.scheduler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Slf4j +@Configuration +@EnableScheduling +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); // 동시 실행 가능한 스레드 수 (직렬 처리 선택) + scheduler.setThreadNamePrefix("scheduler-"); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(30); + scheduler.setErrorHandler(error -> log.error("[Scheduler Error]", error)); + scheduler.initialize(); + return scheduler; + } +} diff --git a/src/test/java/com/ftm/server/post/DeletePostTest.java b/src/test/java/com/ftm/server/post/DeletePostTest.java new file mode 100644 index 0000000..31a2657 --- /dev/null +++ b/src/test/java/com/ftm/server/post/DeletePostTest.java @@ -0,0 +1,189 @@ +package com.ftm.server.post; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.in.post.SavePostUseCase; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.vo.post.PostInfoVo; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +public class DeletePostTest extends BaseTest { + + @Autowired private SavePostUseCase savePostUseCase; + @MockitoSpyBean private S3PostImageUploadPort s3PostImageUploadPort; + @MockitoSpyBean private S3PostProductImageUploadPort s3PostProductImageUploadPort; + @MockitoSpyBean private S3ImageDeletePort s3ImageDeletePort; + @MockitoSpyBean private AfterRollbackExecutorPort afterRollbackExecutorPort; + + private final ParameterDescriptor pathParametersForPostId = + parameterWithName("postId").description("게시글 ID"); + + private final List responseFieldDeletePost = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("응답 데이터")); + + private Long savedPostId; + + private ResultActions getResultActions(MockHttpSession session, Long postId) throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.delete("/api/posts/{postId}", postId) + .session(session)); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "deletePost/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + pathParameters(pathParametersForPostId), + responseFields(responseFieldDeletePost), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("유저픽 게시글 삭제 api") + .description("유저픽 게시글 삭제 api 입니다.") + .responseFields(responseFieldDeletePost) + .build())); + } + + @BeforeEach + void setUp() throws Exception { + User user = createTestUser("test@gmail.com", "test1234!"); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", + GroomingCategory.BEAUTY, + List.of(HashTag.PERFUME), + "
test
", + List.of(new SavePostProductRequest(-1, "독도 토너", "라운드랩", List.of()))); + + // s3 실제 호출 대신 mock 대입 + doReturn(List.of()).when(s3PostImageUploadPort).uploadImages(new ArrayList<>(List.of())); + doReturn(List.of()) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(List.of())); + doNothing().when(s3ImageDeletePort).deleteImages(any()); + doNothing().when(afterRollbackExecutorPort).doAfterRollback(any()); + + PostInfoVo post = + savePostUseCase.execute( + SavePostCommand.from(user.getId(), postRequest, List.of(), List.of())); + savedPostId = post.getId(); + } + + @Test + @Transactional + void 게시글_삭제_성공() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(session, savedPostId); + + // then + resultActions.andExpect(status().isOk()).andDo(print()); + + // Document + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 게시글_삭제_실패1() throws Exception { + // when + ResultActions resultActions = + mockMvc.perform( + RestDocumentationRequestBuilders.delete( + "/api/posts/{postId}", savedPostId)); + + // then + resultActions + .andExpect(status().is(ErrorResponseCode.NOT_AUTHENTICATED.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(2)); + } + + @Test + @Transactional + void 게시글_삭제_실패2() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + // when + ResultActions resultActions = getResultActions(session, 100000L); + + // then + resultActions + .andExpect(status().is(ErrorResponseCode.POST_NOT_FOUND.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(3)); + } + + @Test + @Transactional + void 게시글_삭제_실패3() throws Exception { + // given + MockHttpSession session = createUserAndLogin("test12@gmail.com", "ddddd!"); + + // when + ResultActions resultActions = getResultActions(session, savedPostId); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.UNAUTHORIZED_POST_ACCESS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(4)); + } +} diff --git a/src/test/java/com/ftm/server/post/LoadPostDetailTest.java b/src/test/java/com/ftm/server/post/LoadPostDetailTest.java index bdb5e96..9130240 100644 --- a/src/test/java/com/ftm/server/post/LoadPostDetailTest.java +++ b/src/test/java/com/ftm/server/post/LoadPostDetailTest.java @@ -34,7 +34,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.FieldDescriptor; @@ -106,9 +105,7 @@ public class LoadPostDetailTest extends BaseTest { private Long savedPostId; private ResultActions getResultActions(Long postId) throws Exception { - return mockMvc.perform( - RestDocumentationRequestBuilders.get("/api/posts/{postId}", postId) - .accept(MediaType.APPLICATION_JSON)); + return mockMvc.perform(RestDocumentationRequestBuilders.get("/api/posts/{postId}", postId)); } private RestDocumentationResultHandler getDocument(Integer identifier) { diff --git a/src/test/java/com/ftm/server/post/PostHardDeleteTest.java b/src/test/java/com/ftm/server/post/PostHardDeleteTest.java new file mode 100644 index 0000000..adbffeb --- /dev/null +++ b/src/test/java/com/ftm/server/post/PostHardDeleteTest.java @@ -0,0 +1,100 @@ +package com.ftm.server.post; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; + +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.adapter.out.persistence.repository.PostRepository; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.in.post.PostHardDeleteUseCase; +import com.ftm.server.application.port.in.post.SavePostUseCase; +import com.ftm.server.application.port.out.persistence.post.LoadPostPort; +import com.ftm.server.application.port.out.persistence.post.UpdatePostPort; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.query.FindByIdQuery; +import com.ftm.server.application.vo.post.PostInfoVo; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Transactional; + +public class PostHardDeleteTest extends BaseTest { + + @Autowired private SavePostUseCase savePostUseCase; + @Autowired private PostHardDeleteUseCase postHardDeleteUseCase; + @Autowired private LoadPostPort loadPostPort; + @Autowired private UpdatePostPort updatePostPort; + @Autowired private PostRepository postRepository; + + @MockitoSpyBean private S3PostImageUploadPort s3PostImageUploadPort; + @MockitoSpyBean private S3PostProductImageUploadPort s3PostProductImageUploadPort; + @MockitoSpyBean private S3ImageDeletePort s3ImageDeletePort; + @MockitoSpyBean private AfterRollbackExecutorPort afterRollbackExecutorPort; + + @PersistenceContext private EntityManager em; + + private Long savedPostId; + + @BeforeEach + void setUp() throws Exception { + User user = createTestUser("test@gmail.com", "test1234!"); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", + GroomingCategory.BEAUTY, + List.of(HashTag.PERFUME), + "
test
", + List.of(new SavePostProductRequest(-1, "독도 토너", "라운드랩", List.of()))); + + // s3 실제 호출 대신 mock 대입 + doReturn(List.of()).when(s3PostImageUploadPort).uploadImages(new ArrayList<>(List.of())); + doReturn(List.of()) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(List.of())); + doNothing().when(s3ImageDeletePort).deleteImages(any()); + doNothing().when(afterRollbackExecutorPort).doAfterRollback(any()); + + PostInfoVo post = + savePostUseCase.execute( + SavePostCommand.from(user.getId(), postRequest, List.of(), List.of())); + savedPostId = post.getId(); + } + + @Test + @Transactional + void 게시글_hard_delete_성공() throws Exception { + // given + Post post = loadPostPort.loadPost(FindByIdQuery.of(savedPostId)).get(); + + post.updateIsDeleted(true); + post.updateDeletedAt(LocalDateTime.now().minusDays(30)); + updatePostPort.updatePost(post); + + // when + postHardDeleteUseCase.execute(); + + em.flush(); + em.clear(); + + // then + assertThat(postRepository.findById(savedPostId)).isEmpty(); + } +}