From f401db4eac0b69ec63001512cafbd55a24c90958 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Fri, 25 Apr 2025 18:19:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=ED=94=BD=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/UpdatePostController.java | 40 ++++ .../dto/request/UpdatePostProductRequest.java | 35 ++++ .../post/dto/request/UpdatePostRequest.java | 43 ++++ .../post/PostDomainPersistenceAdapter.java | 75 ++++++- .../persistence/model/PostImageJpaEntity.java | 4 + .../model/PostProductImageJpaEntity.java | 4 + .../model/PostProductJpaEntity.java | 6 + .../PostProductImageCustomRepository.java | 4 +- .../PostProductImageCustomRepositoryImpl.java | 6 +- .../command/post/HasImageIndex.java | 6 + .../command/post/SavePostProductCommand.java | 2 +- .../command/post/UpdatePostCommand.java | 58 ++++++ .../post/UpdatePostProductCommand.java | 30 +++ .../port/in/post/UpdatePostUseCase.java | 10 + .../persistence/post/DeletePostImagePort.java | 11 + .../post/DeletePostProductImagePort.java | 11 + .../post/DeletePostProductPort.java | 11 + .../post/LoadPostProductImagePort.java | 7 +- .../persistence/post/LoadPostProductPort.java | 3 + .../post/UpdatePostProductImagePort.java | 11 + .../post/UpdatePostProductPort.java | 11 + .../application/query/FindByIdsQuery.java | 18 ++ .../query/FindByPostProductIdsQuery.java | 18 -- .../service/post/UpdatePostService.java | 48 +++++ .../post/image/UpdatePostImageService.java | 120 +++++++++++ .../image/UpdatePostProductImageService.java | 190 ++++++++++++++++++ .../product/UpdatePostProductService.java | 168 ++++++++++++++++ .../validator/PostProductValidator.java | 63 ++++++ .../vo/post/PostProductSaveWithImageVo.java | 19 ++ .../vo/post/PostProductUpdateWithImageVo.java | 24 +++ .../response/enums/ErrorResponseCode.java | 8 + .../server/common/utils/CollectionUtils.java | 20 ++ .../com/ftm/server/domain/entity/Post.java | 24 +++ .../ftm/server/domain/entity/PostImage.java | 16 ++ .../ftm/server/domain/entity/PostProduct.java | 16 ++ .../domain/entity/PostProductImage.java | 13 ++ 36 files changed, 1117 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/controller/UpdatePostController.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostProductRequest.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostRequest.java create mode 100644 src/main/java/com/ftm/server/application/command/post/HasImageIndex.java create mode 100644 src/main/java/com/ftm/server/application/command/post/UpdatePostCommand.java create mode 100644 src/main/java/com/ftm/server/application/command/post/UpdatePostProductCommand.java create mode 100644 src/main/java/com/ftm/server/application/port/in/post/UpdatePostUseCase.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostImagePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductImagePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductImagePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductPort.java create mode 100644 src/main/java/com/ftm/server/application/query/FindByIdsQuery.java delete mode 100644 src/main/java/com/ftm/server/application/query/FindByPostProductIdsQuery.java create mode 100644 src/main/java/com/ftm/server/application/service/post/UpdatePostService.java create mode 100644 src/main/java/com/ftm/server/application/service/post/image/UpdatePostImageService.java create mode 100644 src/main/java/com/ftm/server/application/service/post/image/UpdatePostProductImageService.java create mode 100644 src/main/java/com/ftm/server/application/service/post/product/UpdatePostProductService.java create mode 100644 src/main/java/com/ftm/server/application/validator/PostProductValidator.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/PostProductSaveWithImageVo.java create mode 100644 src/main/java/com/ftm/server/application/vo/post/PostProductUpdateWithImageVo.java create mode 100644 src/main/java/com/ftm/server/common/utils/CollectionUtils.java diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/UpdatePostController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/UpdatePostController.java new file mode 100644 index 0000000..fc0415c --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/UpdatePostController.java @@ -0,0 +1,40 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.adapter.in.web.post.dto.request.UpdatePostRequest; +import com.ftm.server.application.command.post.UpdatePostCommand; +import com.ftm.server.application.port.in.post.UpdatePostUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import java.util.List; +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.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class UpdatePostController { + + private final UpdatePostUseCase updatePostUseCase; + + @PatchMapping("/api/posts/{postId}") + public ResponseEntity> updatePost( + @PathVariable Long postId, + @RequestPart(value = "data") UpdatePostRequest request, + @RequestPart(value = "postImageFiles", required = false) + List postImageFiles, + @RequestPart(value = "productImageFiles", required = false) + List productImageFiles, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + updatePostUseCase.execute( + UpdatePostCommand.from( + postId, userPrincipal.getId(), request, postImageFiles, productImageFiles)); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessResponseCode.OK)); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostProductRequest.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostProductRequest.java new file mode 100644 index 0000000..dd8235e --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostProductRequest.java @@ -0,0 +1,35 @@ +package com.ftm.server.adapter.in.web.post.dto.request; + +import com.ftm.server.domain.enums.HashTag; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UpdatePostProductRequest { + + private final Long id; + private final String name; + private final String brand; + private final List hashTags; + private final Long deleteProductImageId; + private final int imageIndex; + + public static UpdatePostProductRequest of( + Long id, + String name, + String brand, + List hashTags, + Long deleteProductImageId, + int imageIndex) { + return UpdatePostProductRequest.builder() + .id(id) + .name(name) + .brand(brand) + .hashTags(hashTags) + .deleteProductImageId(deleteProductImageId) + .imageIndex(imageIndex) + .build(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostRequest.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostRequest.java new file mode 100644 index 0000000..0ca0a6a --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/UpdatePostRequest.java @@ -0,0 +1,43 @@ +package com.ftm.server.adapter.in.web.post.dto.request; + +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UpdatePostRequest { + + private final String title; + private final GroomingCategory groomingCategory; + private final List hashTags; + private final String content; + private final List deletePostImageIds; // 삭제할 게시글 이미지 ID 목록 + + private final List deleteProductIds; // 삭제할 상품 ID 목록 + private final List addProducts; // 새로 추가할 상품 목록 + private final List updateProducts; // 수정할 상품 목록 + + public static UpdatePostRequest of( + String title, + GroomingCategory groomingCategory, + List hashTags, + String content, + List deletePostImageIds, + List deleteProductIds, + List addProducts, + List updateProducts) { + return UpdatePostRequest.builder() + .title(title) + .groomingCategory(groomingCategory) + .hashTags(hashTags) + .content(content) + .deletePostImageIds(deletePostImageIds) + .deleteProductIds(deleteProductIds) + .addProducts(addProducts) + .updateProducts(updateProducts) + .build(); + } +} 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 d6e1302..e7b99ec 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 @@ -4,10 +4,7 @@ import com.ftm.server.adapter.out.persistence.model.*; import com.ftm.server.adapter.out.persistence.repository.*; import com.ftm.server.application.port.out.persistence.post.*; -import com.ftm.server.application.query.FindByIdQuery; -import com.ftm.server.application.query.FindByPostIdQuery; -import com.ftm.server.application.query.FindByPostProductIdsQuery; -import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.query.*; import com.ftm.server.common.annotation.Adapter; import com.ftm.server.common.exception.CustomException; import com.ftm.server.common.response.enums.ErrorResponseCode; @@ -32,7 +29,12 @@ public class PostDomainPersistenceAdapter LoadPostProductImagePort, LoadUserForPostPort, LoadUserImageForPostPort, - UpdatePostPort { + UpdatePostPort, + UpdatePostProductPort, + UpdatePostProductImagePort, + DeletePostImagePort, + DeletePostProductPort, + DeletePostProductImagePort { private final PostRepository postRepository; private final PostImageRepository postImageRepository; @@ -162,14 +164,20 @@ public List loadPostProductsByPostId(FindByPostIdQuery query) { } @Override - public Map loadPostProductImagesByPostProductIds( - FindByPostProductIdsQuery query) { + public List loadPostProductsByIds(FindByIdsQuery query) { + return postProductRepository.findAllById(query.getIds()).stream() + .map(postProductMapper::toDomainEntity) + .toList(); + } + + @Override + public List loadPostProductImagesByPostProductIds(FindByIdsQuery query) { List postProductImageJpaEntities = postProductImageRepository.findByPostProductIds(query); return postProductImageJpaEntities.stream() .map(postProductImageMapper::toDomainEntity) - .collect(Collectors.toMap(PostProductImage::getPostProductId, Function.identity())); + .toList(); } @Override @@ -198,4 +206,55 @@ public void updatePost(Post post) { postJpaEntity.updatePostForDomainEntity(post); } + + @Override + public void updatePostProducts(List postProducts) { + List ids = postProducts.stream().map(PostProduct::getId).toList(); + Map postProductJpaEntityMap = + postProductRepository.findAllById(ids).stream() + .collect( + Collectors.toMap(PostProductJpaEntity::getId, Function.identity())); + + for (PostProduct postProduct : postProducts) { + PostProductJpaEntity postProductJpaEntity = + postProductJpaEntityMap.get(postProduct.getId()); + postProductJpaEntity.updatePostProductForDomainEntity(postProduct); + } + } + + @Override + public void updatePostProductImages(List postProductImages) { + List ids = postProductImages.stream().map(PostProductImage::getId).toList(); + Map postProductImageJpaEntityMap = + postProductImageRepository.findAllById(ids).stream() + .collect( + Collectors.toMap( + PostProductImageJpaEntity::getId, Function.identity())); + + for (PostProductImage postProductImage : postProductImages) { + PostProductImageJpaEntity postProductImageJpaEntity = + postProductImageJpaEntityMap.get(postProductImage.getId()); + postProductImageJpaEntity.updatePostProductImageForDomainEntity(postProductImage); + } + } + + public void deletePostImages(List postImages) { + List ids = postImages.stream().map(PostImage::getId).toList(); + + postImageRepository.deleteAllById(ids); + } + + @Override + public void deletePostProducts(List postProducts) { + List ids = postProducts.stream().map(PostProduct::getId).toList(); + + postProductRepository.deleteAllById(ids); + } + + @Override + public void deletePostProductImages(List postProductImages) { + List ids = postProductImages.stream().map(PostProductImage::getId).toList(); + + postProductImageRepository.deleteAllById(ids); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostImageJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostImageJpaEntity.java index 49ad2ce..6cb7b9b 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostImageJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostImageJpaEntity.java @@ -34,4 +34,8 @@ public static PostImageJpaEntity from(PostImage postImage, PostJpaEntity postJpa .objectKey(postImage.getObjectKey()) .build(); } + + public void updatePostImageForDomainEntity(PostImage postImage) { + this.objectKey = objectKey; + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductImageJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductImageJpaEntity.java index d83af5f..249fd7c 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductImageJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductImageJpaEntity.java @@ -35,4 +35,8 @@ public static PostProductImageJpaEntity from( .objectKey(postProductImage.getObjectKey()) .build(); } + + public void updatePostProductImageForDomainEntity(PostProductImage postProductImage) { + this.objectKey = postProductImage.getObjectKey(); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductJpaEntity.java index 5bf240c..dbaed00 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostProductJpaEntity.java @@ -56,4 +56,10 @@ public static PostProductJpaEntity from(PostProduct postProduct, PostJpaEntity p .hashtags(postProduct.getHashTags()) .build(); } + + public void updatePostProductForDomainEntity(PostProduct postProduct) { + this.name = postProduct.getName(); + this.brand = postProduct.getBrand(); + this.hashtags = postProduct.getHashTags(); + } } 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 index 970092c..22296f3 100644 --- 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 @@ -1,10 +1,10 @@ package com.ftm.server.adapter.out.persistence.repository; import com.ftm.server.adapter.out.persistence.model.PostProductImageJpaEntity; -import com.ftm.server.application.query.FindByPostProductIdsQuery; +import com.ftm.server.application.query.FindByIdsQuery; import java.util.List; public interface PostProductImageCustomRepository { - List findByPostProductIds(FindByPostProductIdsQuery query); + 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 index f8ff760..f40bcc5 100644 --- 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 @@ -3,7 +3,7 @@ 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.FindByPostProductIdsQuery; +import com.ftm.server.application.query.FindByIdsQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -16,10 +16,10 @@ public class PostProductImageCustomRepositoryImpl implements PostProductImageCus private final JPAQueryFactory queryFactory; @Override - public List findByPostProductIds(FindByPostProductIdsQuery query) { + public List findByPostProductIds(FindByIdsQuery query) { return queryFactory .selectFrom(postProductImageJpaEntity) - .where(postProductImageJpaEntity.postProduct.id.in(query.getPostProductIds())) + .where(postProductImageJpaEntity.postProduct.id.in(query.getIds())) .fetch(); } } diff --git a/src/main/java/com/ftm/server/application/command/post/HasImageIndex.java b/src/main/java/com/ftm/server/application/command/post/HasImageIndex.java new file mode 100644 index 0000000..593bf9b --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/HasImageIndex.java @@ -0,0 +1,6 @@ +package com.ftm.server.application.command.post; + +public interface HasImageIndex { + + int getImageIndex(); +} diff --git a/src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java b/src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java index a80af01..a7b5f52 100644 --- a/src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java +++ b/src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java @@ -5,7 +5,7 @@ import lombok.Getter; @Getter -public class SavePostProductCommand { +public class SavePostProductCommand implements HasImageIndex { private final Long postId; // null 가능 (저장 전) private final String name; diff --git a/src/main/java/com/ftm/server/application/command/post/UpdatePostCommand.java b/src/main/java/com/ftm/server/application/command/post/UpdatePostCommand.java new file mode 100644 index 0000000..861df24 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/UpdatePostCommand.java @@ -0,0 +1,58 @@ +package com.ftm.server.application.command.post; + +import com.ftm.server.adapter.in.web.post.dto.request.UpdatePostRequest; +import com.ftm.server.common.utils.CollectionUtils; +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import java.util.List; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +public class UpdatePostCommand { + + private final Long id; + private final Long userId; + private final String title; + private final GroomingCategory groomingCategory; + private final HashTag[] hashTags; + private final String content; + private final List deletePostImageIds; + private final List deleteProductIds; + private final List addProducts; + private final List updateProducts; + private final List postImageFiles; + private final List productImageFiles; + + private UpdatePostCommand( + Long id, + Long userId, + UpdatePostRequest request, + List postImageFiles, + List productImageFiles) { + this.id = id; + this.userId = userId; + this.title = request.getTitle(); + this.groomingCategory = request.getGroomingCategory(); + this.hashTags = CollectionUtils.listToArrayOrNull(request.getHashTags(), HashTag[]::new); + this.content = request.getContent(); + this.deletePostImageIds = CollectionUtils.safeList(request.getDeletePostImageIds()); + this.deleteProductIds = CollectionUtils.safeList(request.getDeleteProductIds()); + this.addProducts = + CollectionUtils.mapOrEmpty(request.getAddProducts(), SavePostProductCommand::from); + this.updateProducts = + CollectionUtils.mapOrEmpty( + request.getUpdateProducts(), UpdatePostProductCommand::from); + this.postImageFiles = CollectionUtils.safeList(postImageFiles); + this.productImageFiles = CollectionUtils.safeList(productImageFiles); + } + + public static UpdatePostCommand from( + Long id, + Long userId, + UpdatePostRequest request, + List postImageFiles, + List productImageFiles) { + return new UpdatePostCommand(id, userId, request, postImageFiles, productImageFiles); + } +} diff --git a/src/main/java/com/ftm/server/application/command/post/UpdatePostProductCommand.java b/src/main/java/com/ftm/server/application/command/post/UpdatePostProductCommand.java new file mode 100644 index 0000000..890fab7 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/UpdatePostProductCommand.java @@ -0,0 +1,30 @@ +package com.ftm.server.application.command.post; + +import com.ftm.server.adapter.in.web.post.dto.request.UpdatePostProductRequest; +import com.ftm.server.common.utils.CollectionUtils; +import com.ftm.server.domain.enums.HashTag; +import lombok.Getter; + +@Getter +public class UpdatePostProductCommand implements HasImageIndex { + + private final Long id; + private final String name; + private final String brand; + private final HashTag[] hashTags; + private final Long deleteProductImageId; + private final int imageIndex; + + private UpdatePostProductCommand(UpdatePostProductRequest request) { + this.id = request.getId(); + this.name = request.getName(); + this.brand = request.getBrand(); + this.hashTags = CollectionUtils.listToArrayOrNull(request.getHashTags(), HashTag[]::new); + this.deleteProductImageId = request.getDeleteProductImageId(); + this.imageIndex = request.getImageIndex(); + } + + public static UpdatePostProductCommand from(UpdatePostProductRequest request) { + return new UpdatePostProductCommand(request); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/UpdatePostUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/UpdatePostUseCase.java new file mode 100644 index 0000000..1aa0274 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/UpdatePostUseCase.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.application.command.post.UpdatePostCommand; +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface UpdatePostUseCase { + + void execute(UpdatePostCommand command); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostImagePort.java new file mode 100644 index 0000000..732da25 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostImagePort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostImage; +import java.util.List; + +@Port +public interface DeletePostImagePort { + + void deletePostImages(List postImages); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductImagePort.java new file mode 100644 index 0000000..94fcd71 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductImagePort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostProductImage; +import java.util.List; + +@Port +public interface DeletePostProductImagePort { + + void deletePostProductImages(List postProductImages); +} 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 new file mode 100644 index 0000000..9f14dfc --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/DeletePostProductPort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostProduct; +import java.util.List; + +@Port +public interface DeletePostProductPort { + + void deletePostProducts(List postProducts); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductImagePort.java index ffb915d..eef225e 100644 --- a/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductImagePort.java +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/LoadPostProductImagePort.java @@ -1,13 +1,12 @@ package com.ftm.server.application.port.out.persistence.post; -import com.ftm.server.application.query.FindByPostProductIdsQuery; +import com.ftm.server.application.query.FindByIdsQuery; import com.ftm.server.common.annotation.Port; import com.ftm.server.domain.entity.PostProductImage; -import java.util.Map; +import java.util.List; @Port public interface LoadPostProductImagePort { - Map loadPostProductImagesByPostProductIds( - FindByPostProductIdsQuery query); + List loadPostProductImagesByPostProductIds(FindByIdsQuery 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 189ec24..adba404 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 @@ -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.PostProduct; @@ -9,4 +10,6 @@ public interface LoadPostProductPort { List loadPostProductsByPostId(FindByPostIdQuery query); + + List loadPostProductsByIds(FindByIdsQuery query); } diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductImagePort.java new file mode 100644 index 0000000..0564454 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductImagePort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostProductImage; +import java.util.List; + +@Port +public interface UpdatePostProductImagePort { + + void updatePostProductImages(List postProductImages); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductPort.java new file mode 100644 index 0000000..055b6c8 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/UpdatePostProductPort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.PostProduct; +import java.util.List; + +@Port +public interface UpdatePostProductPort { + + void updatePostProducts(List postProducts); +} diff --git a/src/main/java/com/ftm/server/application/query/FindByIdsQuery.java b/src/main/java/com/ftm/server/application/query/FindByIdsQuery.java new file mode 100644 index 0000000..81ecbb4 --- /dev/null +++ b/src/main/java/com/ftm/server/application/query/FindByIdsQuery.java @@ -0,0 +1,18 @@ +package com.ftm.server.application.query; + +import java.util.List; +import lombok.Getter; + +@Getter +public class FindByIdsQuery { + + private final List ids; + + private FindByIdsQuery(List ids) { + this.ids = ids; + } + + public static FindByIdsQuery from(List ids) { + return new FindByIdsQuery(ids); + } +} diff --git a/src/main/java/com/ftm/server/application/query/FindByPostProductIdsQuery.java b/src/main/java/com/ftm/server/application/query/FindByPostProductIdsQuery.java deleted file mode 100644 index ca89554..0000000 --- a/src/main/java/com/ftm/server/application/query/FindByPostProductIdsQuery.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ftm.server.application.query; - -import java.util.List; -import lombok.Getter; - -@Getter -public class FindByPostProductIdsQuery { - - private final List postProductIds; - - private FindByPostProductIdsQuery(List postProductIds) { - this.postProductIds = postProductIds; - } - - public static FindByPostProductIdsQuery of(List postProductIds) { - return new FindByPostProductIdsQuery(postProductIds); - } -} diff --git a/src/main/java/com/ftm/server/application/service/post/UpdatePostService.java b/src/main/java/com/ftm/server/application/service/post/UpdatePostService.java new file mode 100644 index 0000000..4d226b3 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/UpdatePostService.java @@ -0,0 +1,48 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.command.post.UpdatePostCommand; +import com.ftm.server.application.port.in.post.UpdatePostUseCase; +import com.ftm.server.application.port.out.persistence.post.*; +import com.ftm.server.application.query.FindByIdQuery; +import com.ftm.server.application.service.post.image.UpdatePostImageService; +import com.ftm.server.application.service.post.product.UpdatePostProductService; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UpdatePostService implements UpdatePostUseCase { + + private final LoadPostPort loadPostPort; + private final UpdatePostPort updatePostPort; + + private final UpdatePostImageService updatePostImageService; + private final UpdatePostProductService updatePostProductService; + + @Override + @Transactional + public void execute(UpdatePostCommand command) { + // 게시글 조회 + Post post = + loadPostPort + .loadPost(FindByIdQuery.of(command.getId())) + .orElseThrow(() -> new CustomException(ErrorResponseCode.POST_NOT_FOUND)); + post.validateDeleted(); + post.validateWriter(command.getUserId()); + + // 게시글 업데이트 + post.update(command); + updatePostPort.updatePost(post); + + // 게시글 이미지 업데이트 + updatePostImageService.execute( + post, command.getDeletePostImageIds(), command.getPostImageFiles()); + + // 상품, 상품 이미지 업데이트 + updatePostProductService.execute(post, command); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/image/UpdatePostImageService.java b/src/main/java/com/ftm/server/application/service/post/image/UpdatePostImageService.java new file mode 100644 index 0000000..bb7279b --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/image/UpdatePostImageService.java @@ -0,0 +1,120 @@ +package com.ftm.server.application.service.post.image; + +import com.ftm.server.application.port.out.persistence.post.DeletePostImagePort; +import com.ftm.server.application.port.out.persistence.post.LoadPostImagePort; +import com.ftm.server.application.port.out.persistence.post.SavePostImagePort; +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.transcation.AfterCommitExecutorPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.query.FindByPostIdQuery; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostImage; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class UpdatePostImageService { + + private final LoadPostImagePort loadPostImagePort; + private final DeletePostImagePort deletePostImagePort; + private final SavePostImagePort savePostImagePort; + + private final S3PostImageUploadPort s3PostImageUploadPort; + private final S3ImageDeletePort s3ImageDeletePort; + + private final AfterRollbackExecutorPort afterRollbackExecutorPort; + private final AfterCommitExecutorPort afterCommitExecutorPort; + + public void execute( + Post post, List deletePostImageIds, List addPostImageFiles) { + // 삭제, 추가할 이미지가 없으면 return + if (deletePostImageIds.isEmpty() && addPostImageFiles.isEmpty()) return; + + // 게시글 이미지 업로드 + List uploadedPostImageObjectKeys = + s3PostImageUploadPort.uploadImages(addPostImageFiles); + registerRollbackHook(uploadedPostImageObjectKeys); + + // 현재 게시글 이미지 목록 + List currentImages = + loadPostImagePort.loadPostImagesByPostId(FindByPostIdQuery.of(post.getId())); + + // 기존 이미지가 기본 이미지이고, 새로 추가한 이미지가 존재할 경우 + // 기본 이미지 삭제 + if (currentImages.size() == 1 + && currentImages.get(0).isDefaultImage() + && !uploadedPostImageObjectKeys.isEmpty()) { + deletePostImagePort.deletePostImages(List.of(currentImages.get(0))); + } + + // 삭제할 이미지가 있는 경우 + int remainImageCount = -1; // 삭제 이후 남은 이미지 수 + if (!deletePostImageIds.isEmpty()) { + remainImageCount = deletePostImages(deletePostImageIds, currentImages); + } + + // 모든 이미지가 삭제되고, 새로 추가한 이미지가 없으면 기본 이미지 저장 + if (remainImageCount == 0 && uploadedPostImageObjectKeys.isEmpty()) { + savePostImagePort.savePostImages(List.of(PostImage.createDefault(post.getId()))); + return; + } + + // 새로 추가한 이미지가 있을 경우 + if (!uploadedPostImageObjectKeys.isEmpty()) { + savePostImages(post, uploadedPostImageObjectKeys); + } + } + + private int deletePostImages(List deletePostImageIds, List currentImages) { + Set deletePostImageIdsSet = new HashSet<>(deletePostImageIds); + + List imagesToDelete = + currentImages.stream() + .filter(image -> deletePostImageIdsSet.contains(image.getId())) + .toList(); + + if (imagesToDelete.size() != deletePostImageIds.size()) + throw new CustomException(ErrorResponseCode.UNAUTHORIZED_POST_IMAGE_ACCESS); + + // 삭제할 게시글 이미지 검증 + for (PostImage postImage : imagesToDelete) { + postImage.validateDefaultImage(); + } + + // 삭제할 게시글 이미지 파일 objectKey 목록 + List deletePostImageObjectKeys = + imagesToDelete.stream().map(PostImage::getObjectKey).toList(); + registerCommitHook(deletePostImageObjectKeys); // 커밋 이후, S3 이미지 삭제 예약 + + deletePostImagePort.deletePostImages(imagesToDelete); + + return currentImages.size() - imagesToDelete.size(); + } + + private void savePostImages(Post post, List uploadedPostImageObjectKeys) { + List imagesToSave = + uploadedPostImageObjectKeys.stream() + .map(objectKey -> PostImage.create(post.getId(), objectKey)) + .toList(); + + savePostImagePort.savePostImages(imagesToSave); + } + + private void registerRollbackHook(List uploadedPostImageObjectKeys) { + afterRollbackExecutorPort.doAfterRollback( + () -> s3ImageDeletePort.deleteImages(uploadedPostImageObjectKeys)); + } + + private void registerCommitHook(List deletePostImageObjectKeys) { + afterCommitExecutorPort.doAfterCommit( + () -> s3ImageDeletePort.deleteImages(deletePostImageObjectKeys)); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/image/UpdatePostProductImageService.java b/src/main/java/com/ftm/server/application/service/post/image/UpdatePostProductImageService.java new file mode 100644 index 0000000..26857b5 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/image/UpdatePostProductImageService.java @@ -0,0 +1,190 @@ +package com.ftm.server.application.service.post.image; + +import static com.ftm.server.common.consts.PropertiesHolder.PRODUCT_DEFAULT_IMAGE; + +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.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterCommitExecutorPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.vo.post.PostProductSaveWithImageVo; +import com.ftm.server.application.vo.post.PostProductUpdateWithImageVo; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.PostProductImage; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class UpdatePostProductImageService { + + private final LoadPostProductImagePort loadPostProductImagePort; + private final UpdatePostProductImagePort updatePostProductImagePort; + private final SavePostProductImagePort savePostProductImagePort; + private final DeletePostProductImagePort deletePostProductImagePort; + + private final S3PostProductImageUploadPort s3PostProductImageUploadPort; + private final S3ImageDeletePort s3ImageDeletePort; + + private final AfterRollbackExecutorPort afterRollbackExecutorPort; + private final AfterCommitExecutorPort afterCommitExecutorPort; + + private final List deleteProductImageObjectKeys = new ArrayList<>(); + + public void execute( + List productImageFiles, + List deleteProductIds, + List productImageUpdateContexts, + List productImageSaveContexts) { + + // 상품 이미지 업로드 + List uploadedProductImageObjectKeys = + s3PostProductImageUploadPort.uploadImages(productImageFiles); + registerRollbackHook(uploadedProductImageObjectKeys); // 롤백 시, 업로드된 S3 이미지 삭제 예약 + + // 업데이트할 상품 이미지가 있을 경우 + if (!productImageUpdateContexts.isEmpty()) { + updatePostProductImages(productImageUpdateContexts, uploadedProductImageObjectKeys); + } + + // 저장할 상품 이미지가 있을 경우 + if (!productImageSaveContexts.isEmpty()) { + savePostProductImages(productImageSaveContexts, uploadedProductImageObjectKeys); + } + + // 삭제할 상품 이미지가 있을 경우 + if (!deleteProductIds.isEmpty()) { + deletePostProductImages(deleteProductIds); + } + + // 커밋 이후, S3 이미지 삭제 예약 + registerCommitHook(); + } + + // 상품 이미지 업데이트 + private void updatePostProductImages( + List productImageUpdateContexts, + List uploadedProductImageObjectKeys) { + List updatedProductIds = + productImageUpdateContexts.stream() + .map(PostProductUpdateWithImageVo::getPostProductId) + .toList(); + + // 상품 ID : 해당 상품 이미지 map + Map updateProductImageMap = + loadPostProductImagePort + .loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(updatedProductIds)) + .stream() + .collect( + Collectors.toMap( + PostProductImage::getPostProductId, Function.identity())); + + List imagesToUpdate = new ArrayList<>(); + for (PostProductUpdateWithImageVo context : productImageUpdateContexts) { + PostProductImage postProductImage = + updateProductImageMap.get(context.getPostProductId()); + + // 상품 이미지 검증 + if (context.getDeletePostProductImageId() != null) { + postProductImage.validateId(context.getDeletePostProductImageId()); + } + + String currentKey = postProductImage.getObjectKey(); + String objectKey = PRODUCT_DEFAULT_IMAGE; + + // 1. 기본 이미지인데 삭제 요청 + if (PRODUCT_DEFAULT_IMAGE.equals(currentKey) + && context.getDeletePostProductImageId() != null) { + throw new CustomException(ErrorResponseCode.CANNOT_DELETE_DEFAULT_IMAGE); + } + + // 2. 기존 이미지 존재 + 삭제 요청 없이 새 이미지 업로드 + if (!PRODUCT_DEFAULT_IMAGE.equals(currentKey) + && context.getDeletePostProductImageId() == null + && context.getImageIndex() > 0) { + throw new CustomException(ErrorResponseCode.POST_PRODUCT_IMAGE_ALREADY_EXISTS); + } + + // 3. 삭제할 이미지가 존재할 경우 + if (context.getDeletePostProductImageId() != null) { + deleteProductImageObjectKeys.add(currentKey); + + // 4. 새로 추가할 이미지가 존재할 경우 + if (context.getImageIndex() > 0) { + objectKey = uploadedProductImageObjectKeys.get(context.getImageIndex() - 1); + } + } + + // 5. 기본 이미지 -> 새 이미지 업로드 + if (context.getDeletePostProductImageId() == null && context.getImageIndex() > 0) { + objectKey = uploadedProductImageObjectKeys.get(context.getImageIndex() - 1); + } + + postProductImage.updateObjectKey(objectKey); + imagesToUpdate.add(postProductImage); + } + + updatePostProductImagePort.updatePostProductImages(imagesToUpdate); + } + + // 상품 이미지 저장 + private void savePostProductImages( + List productImageSaveContexts, + List uploadedProductImageObjectKeys) { + List imagesToSave = new ArrayList<>(); + for (PostProductSaveWithImageVo context : productImageSaveContexts) { + int imageIndex = context.getImageIndex(); + + // 이미지가 없는 경우, 기본 이미지 + if (imageIndex == -1) { + imagesToSave.add(PostProductImage.createDefault(context.getPostProductId())); + continue; + } + + imagesToSave.add( + PostProductImage.create( + context.getPostProductId(), + uploadedProductImageObjectKeys.get(imageIndex - 1))); + } + + savePostProductImagePort.savePostProductImages(imagesToSave); + } + + // 상품 이미지 삭제 + private void deletePostProductImages(List deleteProductIds) { + List deleteProductImages = + loadPostProductImagePort.loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(deleteProductIds)); + + for (PostProductImage postProductImage : deleteProductImages) { + String objectKey = postProductImage.getObjectKey(); + if (PRODUCT_DEFAULT_IMAGE.equals(objectKey)) continue; + + deleteProductImageObjectKeys.add(objectKey); + } + + // 상품 이미지 삭제 + deletePostProductImagePort.deletePostProductImages(deleteProductImages); + } + + private void registerRollbackHook(List uploadedProductImageObjectKeys) { + afterRollbackExecutorPort.doAfterRollback( + () -> s3ImageDeletePort.deleteImages(uploadedProductImageObjectKeys)); + } + + private void registerCommitHook() { + afterCommitExecutorPort.doAfterCommit( + () -> { + s3ImageDeletePort.deleteImages(this.deleteProductImageObjectKeys); + }); + } +} diff --git a/src/main/java/com/ftm/server/application/service/post/product/UpdatePostProductService.java b/src/main/java/com/ftm/server/application/service/post/product/UpdatePostProductService.java new file mode 100644 index 0000000..3d5a0a4 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/product/UpdatePostProductService.java @@ -0,0 +1,168 @@ +package com.ftm.server.application.service.post.product; + +import com.ftm.server.application.command.post.HasImageIndex; +import com.ftm.server.application.command.post.SavePostProductCommand; +import com.ftm.server.application.command.post.UpdatePostCommand; +import com.ftm.server.application.command.post.UpdatePostProductCommand; +import com.ftm.server.application.port.out.persistence.post.*; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.service.post.image.UpdatePostProductImageService; +import com.ftm.server.application.validator.PostProductValidator; +import com.ftm.server.application.vo.post.PostProductSaveWithImageVo; +import com.ftm.server.application.vo.post.PostProductUpdateWithImageVo; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.Post; +import com.ftm.server.domain.entity.PostProduct; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class UpdatePostProductService { + + private final LoadPostProductPort loadPostProductPort; + private final UpdatePostProductPort updatePostProductPort; + private final SavePostProductPort savePostProductPort; + private final DeletePostProductPort deletePostProductPort; + + private final UpdatePostProductImageService updatePostProductImageService; + + public void execute(Post post, UpdatePostCommand command) { + + // 업데이트, 저장, 삭제할 상품 목록 + List updateProducts = command.getUpdateProducts(); + List saveProducts = command.getAddProducts(); + List deleteProductIds = command.getDeleteProductIds(); + + // 상품 : 상품 이미지 검증 + validateProductsWithImage(updateProducts, saveProducts, command.getProductImageFiles()); + + // 삭제, 업데이트, 저장할 상품이 없으면 return + if (deleteProductIds.isEmpty() && updateProducts.isEmpty() && saveProducts.isEmpty()) + return; + + List productImageUpdateContexts = List.of(); + List productImageSaveContexts = new ArrayList<>(); + + // 업데이트할 상품이 있을 경우 + if (!updateProducts.isEmpty()) { + productImageUpdateContexts = updateProducts(post, updateProducts); + } + + // 새로 추가할 상품이 있을 경우 + if (!saveProducts.isEmpty()) { + List saved = saveProducts(post, saveProducts); + + for (int i = 0; i < saved.size(); i++) { + PostProduct postProduct = saved.get(i); + SavePostProductCommand cmd = saveProducts.get(i); + + productImageSaveContexts.add( + PostProductSaveWithImageVo.of(postProduct.getId(), cmd.getImageIndex())); + } + } + + // 상품 이미지 삭제를 먼저하기 위해 상품 이미지 업데이트 먼저 수행 + updatePostProductImageService.execute( + command.getProductImageFiles(), + deleteProductIds, + productImageUpdateContexts, + productImageSaveContexts); + + // 삭제할 상품이 있는 경우 + if (!deleteProductIds.isEmpty()) { + deleteProducts(post, deleteProductIds); + } + } + + private void validateProductsWithImage( + List updateProducts, + List saveProducts, + List productImageFiles) { + List mergedProducts = + Stream.concat(updateProducts.stream(), saveProducts.stream()).toList(); + + PostProductValidator.validateImageIndexRange(mergedProducts, productImageFiles); + PostProductValidator.validateImageIndexDuplication(mergedProducts); + PostProductValidator.validateOneToOneImageProductMapping(mergedProducts, productImageFiles); + } + + // 상품 업데이트 + private List updateProducts( + Post post, List updateProducts) { + List productImageUpdateContexts = new ArrayList<>(); + List updateProductIds = + updateProducts.stream().map(UpdatePostProductCommand::getId).toList(); + + // 업데이트할 상품 Map + Map updateProductMap = + loadPostProductPort + .loadPostProductsByIds(FindByIdsQuery.from(updateProductIds)) + .stream() + .collect(Collectors.toMap(PostProduct::getId, Function.identity())); + + // 업데이트할 상품 검증 + validateProducts(post, updateProductMap.values().stream().toList()); + + List productsToUpdate = new ArrayList<>(); + for (UpdatePostProductCommand cmd : updateProducts) { + PostProduct postProduct = updateProductMap.get(cmd.getId()); + postProduct.update(cmd); + productsToUpdate.add(postProduct); + + // 이미지 변경이 없는 경우 + if (cmd.getDeleteProductImageId() == null && cmd.getImageIndex() == -1) continue; + + productImageUpdateContexts.add( + PostProductUpdateWithImageVo.of( + postProduct.getId(), + cmd.getDeleteProductImageId(), + cmd.getImageIndex())); + } + + updatePostProductPort.updatePostProducts(productsToUpdate); + + return productImageUpdateContexts; + } + + // 상품 추가 + private List saveProducts(Post post, List saveProducts) { + List productsToSave = new ArrayList<>(); + for (SavePostProductCommand cmd : saveProducts) { + PostProduct postProduct = PostProduct.create(cmd.withPostId(post.getId())); + productsToSave.add(postProduct); + } + + return savePostProductPort.savePostProducts(productsToSave); + } + + // 상품 삭제 + private void deleteProducts(Post post, List deleteProductIds) { + // 삭제할 상품 목록 + List deleteProducts = + loadPostProductPort.loadPostProductsByIds(FindByIdsQuery.from(deleteProductIds)); + + // 삭제할 상품이 게시글에 포함된 상품인지 검증 + validateProducts(post, deleteProducts); + + // 상품 삭제 + deletePostProductPort.deletePostProducts(deleteProducts); + } + + private void validateProducts(Post post, List products) { + if (products.isEmpty()) + throw new CustomException(ErrorResponseCode.UNAUTHORIZED_POST_PRODUCT_ACCESS); + + for (PostProduct postProduct : products) { + postProduct.validatePost(post.getId()); + } + } +} diff --git a/src/main/java/com/ftm/server/application/validator/PostProductValidator.java b/src/main/java/com/ftm/server/application/validator/PostProductValidator.java new file mode 100644 index 0000000..2a859f6 --- /dev/null +++ b/src/main/java/com/ftm/server/application/validator/PostProductValidator.java @@ -0,0 +1,63 @@ +package com.ftm.server.application.validator; + +import com.ftm.server.application.command.post.HasImageIndex; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +public class PostProductValidator { + + // 이미지 범위 검증 + public static void validateImageIndexRange( + List data, List imageFiles) { + int maxIndex = imageFiles.size(); + + for (int imageIndex : getValidImageIndexes(data)) { + System.out.println(imageIndex); + if (imageIndex <= 0 || imageIndex > maxIndex) { + log.warn("imageIndex 범위 검증 실패: index={}, maxIndex={}", imageIndex, maxIndex); + throw new CustomException(ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING); + } + } + } + + // 중복된 이미지 인덱스 검증 + public static void validateImageIndexDuplication(List data) { + Set seen = new HashSet<>(); + + for (int imageIndex : getValidImageIndexes(data)) { + if (!seen.add(imageIndex)) { + log.warn("중복된 imageIndex: index={}", imageIndex); + throw new CustomException(ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING); + } + } + } + + // 이미지를 등록하지 않은 상품을 제외한 상품들의 imageIndex 목록 조회 + private static List getValidImageIndexes(List data) { + return data.stream() + .map(HasImageIndex::getImageIndex) + .filter(index -> index != -1) + .toList(); + } + + // 상품 : 이미지 매핑 검증 + public static void validateOneToOneImageProductMapping( + List data, List imageFiles) { + long imageCount = imageFiles.size(); + long productCount = + data.stream() // 이미지를 등록하지 않은 상품을 제외한 수 + .filter(product -> product.getImageIndex() != -1) + .count(); + + if (imageCount != productCount) { + log.warn("이미지 매핑 불일치: 이미지를 등록한 데이터 개수={}, 업로드된 이미지 수={}", productCount, imageCount); + throw new CustomException(ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING); + } + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostProductSaveWithImageVo.java b/src/main/java/com/ftm/server/application/vo/post/PostProductSaveWithImageVo.java new file mode 100644 index 0000000..c10ab7d --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/PostProductSaveWithImageVo.java @@ -0,0 +1,19 @@ +package com.ftm.server.application.vo.post; + +import lombok.Getter; + +@Getter +public class PostProductSaveWithImageVo { + + private final Long postProductId; + private final int imageIndex; + + private PostProductSaveWithImageVo(Long postProductId, int imageIndex) { + this.postProductId = postProductId; + this.imageIndex = imageIndex; + } + + public static PostProductSaveWithImageVo of(Long postProductId, int imageIndex) { + return new PostProductSaveWithImageVo(postProductId, imageIndex); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/post/PostProductUpdateWithImageVo.java b/src/main/java/com/ftm/server/application/vo/post/PostProductUpdateWithImageVo.java new file mode 100644 index 0000000..858b9ee --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/post/PostProductUpdateWithImageVo.java @@ -0,0 +1,24 @@ +package com.ftm.server.application.vo.post; + +import lombok.Getter; + +@Getter +public class PostProductUpdateWithImageVo { + + private final Long postProductId; + private final Long deletePostProductImageId; + private final int imageIndex; + + private PostProductUpdateWithImageVo( + Long postProductId, Long deletePostProductImageId, int imageIndex) { + this.postProductId = postProductId; + this.deletePostProductImageId = deletePostProductImageId; + this.imageIndex = imageIndex; + } + + public static PostProductUpdateWithImageVo of( + Long postProductId, Long deletePostProductImageId, int imageIndex) { + return new PostProductUpdateWithImageVo( + postProductId, deletePostProductImageId, imageIndex); + } +} diff --git a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java index 0c3d35e..b4e77a7 100644 --- a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java +++ b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java @@ -23,6 +23,9 @@ public enum ErrorResponseCode { HttpStatus.BAD_REQUEST, "E400_008", "상품과 이미지 간의 매핑이 올바르지 않습니다. imageIndex와 이미지 수를 확인해주세요."), + CANNOT_DELETE_DEFAULT_IMAGE(HttpStatus.BAD_REQUEST, "E400_009", "기본 이미지는 삭제할 수 없습니다."), + POST_PRODUCT_IMAGE_ALREADY_EXISTS( + HttpStatus.BAD_REQUEST, "E400_010", "이미 이미지가 존재합니다. 기존 이미지를 삭제하고 업로드 해주세요."), // 401번 NOT_AUTHENTICATED(HttpStatus.UNAUTHORIZED, "E401_001", "인증되지 않은 사용자입니다."), @@ -30,6 +33,11 @@ public enum ErrorResponseCode { // 403번 NOT_AUTHORIZATION(HttpStatus.FORBIDDEN, "E403_001", "인증된 사용자이나 해당 자원에 대한 접근 권한이 없습니다."), + UNAUTHORIZED_POST_ACCESS(HttpStatus.FORBIDDEN, "E403_002", "해당 게시글에 대한 권한이 없습니다."), + UNAUTHORIZED_POST_IMAGE_ACCESS(HttpStatus.FORBIDDEN, "E403_003", "해당 게시글 이미지에 대한 권한이 없습니다."), + UNAUTHORIZED_POST_PRODUCT_ACCESS(HttpStatus.FORBIDDEN, "E403_004", "해당 상품에 대한 권한이 없습니다."), + UNAUTHORIZED_POST_PRODUCT_IMAGE_ACCESS( + HttpStatus.FORBIDDEN, "E403_005", "해당 상품 이미지에 대한 권한이 없습니다."), // 404번 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_001", "요청된 사용자를 찾을 수 없습니다."), diff --git a/src/main/java/com/ftm/server/common/utils/CollectionUtils.java b/src/main/java/com/ftm/server/common/utils/CollectionUtils.java new file mode 100644 index 0000000..bb41558 --- /dev/null +++ b/src/main/java/com/ftm/server/common/utils/CollectionUtils.java @@ -0,0 +1,20 @@ +package com.ftm.server.common.utils; + +import java.util.List; +import java.util.function.Function; +import java.util.function.IntFunction; + +public class CollectionUtils { + + public static List safeList(List list) { + return list != null ? list : List.of(); + } + + public static List mapOrEmpty(List list, Function mapper) { + return list != null ? list.stream().map(mapper).toList() : List.of(); + } + + public static T[] listToArrayOrNull(List list, IntFunction arrayConstructor) { + return list != null ? list.toArray(arrayConstructor.apply(0)) : null; + } +} 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 c16d987..344d468 100644 --- a/src/main/java/com/ftm/server/domain/entity/Post.java +++ b/src/main/java/com/ftm/server/domain/entity/Post.java @@ -1,9 +1,13 @@ package com.ftm.server.domain.entity; import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.command.post.UpdatePostCommand; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; import com.ftm.server.domain.enums.GroomingCategory; import com.ftm.server.domain.enums.HashTag; import java.time.LocalDateTime; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -97,4 +101,24 @@ public static Post create(SavePostCommand command) { 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(); + if (command.getGroomingCategory() != null) + this.groomingCategory = command.getGroomingCategory(); + 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 validateWriter(Long userId) { + if (!Objects.equals(this.userId, userId)) { + throw new CustomException(ErrorResponseCode.UNAUTHORIZED_POST_ACCESS); + } + } } diff --git a/src/main/java/com/ftm/server/domain/entity/PostImage.java b/src/main/java/com/ftm/server/domain/entity/PostImage.java index dca99a2..1a24e2c 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostImage.java +++ b/src/main/java/com/ftm/server/domain/entity/PostImage.java @@ -2,6 +2,8 @@ import static com.ftm.server.common.consts.PropertiesHolder.POST_DEFAULT_IMAGE; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -52,4 +54,18 @@ public static PostImage create(Long postId, String objectKey) { public static PostImage createDefault(Long postId) { return PostImage.builder().postId(postId).objectKey(POST_DEFAULT_IMAGE).build(); } + + public void updateObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public boolean isDefaultImage() { + return POST_DEFAULT_IMAGE.equals(this.objectKey); + } + + public void validateDefaultImage() { + if (POST_DEFAULT_IMAGE.equals(this.objectKey)) { + throw new CustomException(ErrorResponseCode.CANNOT_DELETE_DEFAULT_IMAGE); + } + } } diff --git a/src/main/java/com/ftm/server/domain/entity/PostProduct.java b/src/main/java/com/ftm/server/domain/entity/PostProduct.java index 1b6baea..984ef33 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostProduct.java +++ b/src/main/java/com/ftm/server/domain/entity/PostProduct.java @@ -1,8 +1,12 @@ package com.ftm.server.domain.entity; import com.ftm.server.application.command.post.SavePostProductCommand; +import com.ftm.server.application.command.post.UpdatePostProductCommand; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; import com.ftm.server.domain.enums.HashTag; import java.time.LocalDateTime; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -63,4 +67,16 @@ public static PostProduct create(SavePostProductCommand command) { .hashTags(command.getHashTags()) .build(); } + + public void update(UpdatePostProductCommand command) { + if (command.getName() != null) this.name = command.getName(); + if (command.getBrand() != null) this.brand = command.getBrand(); + if (command.getHashTags() != null) this.hashTags = command.getHashTags(); + } + + public void validatePost(Long postId) { + if (!Objects.equals(this.postId, postId)) { + throw new CustomException(ErrorResponseCode.UNAUTHORIZED_POST_PRODUCT_ACCESS); + } + } } diff --git a/src/main/java/com/ftm/server/domain/entity/PostProductImage.java b/src/main/java/com/ftm/server/domain/entity/PostProductImage.java index f4e23ca..709e401 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostProductImage.java +++ b/src/main/java/com/ftm/server/domain/entity/PostProductImage.java @@ -2,7 +2,10 @@ import static com.ftm.server.common.consts.PropertiesHolder.PRODUCT_DEFAULT_IMAGE; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; import java.time.LocalDateTime; +import java.util.Objects; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -55,4 +58,14 @@ public static PostProductImage createDefault(Long postProductId) { .objectKey(PRODUCT_DEFAULT_IMAGE) .build(); } + + public void updateObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public void validateId(Long postProductImageId) { + if (!Objects.equals(this.id, postProductImageId)) { + throw new CustomException(ErrorResponseCode.UNAUTHORIZED_POST_PRODUCT_IMAGE_ACCESS); + } + } } From cd45ead3c3a5982aac8e1a4e3a54e943311a753a Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Fri, 25 Apr 2025 18:27:42 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=ED=94=BD=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=9C=20=EA=B2=8C=EC=8B=9C=EA=B8=80=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/post/LoadPostDetailService.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/ftm/server/application/service/post/LoadPostDetailService.java b/src/main/java/com/ftm/server/application/service/post/LoadPostDetailService.java index 0d99515..602677d 100644 --- a/src/main/java/com/ftm/server/application/service/post/LoadPostDetailService.java +++ b/src/main/java/com/ftm/server/application/service/post/LoadPostDetailService.java @@ -2,16 +2,15 @@ import com.ftm.server.application.port.in.post.LoadPostDetailUseCase; import com.ftm.server.application.port.out.persistence.post.*; -import com.ftm.server.application.query.FindByIdQuery; -import com.ftm.server.application.query.FindByPostIdQuery; -import com.ftm.server.application.query.FindByPostProductIdsQuery; -import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.query.*; import com.ftm.server.application.vo.post.PostDetailVo; import com.ftm.server.common.exception.CustomException; import com.ftm.server.common.response.enums.ErrorResponseCode; import com.ftm.server.domain.entity.*; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -38,9 +37,7 @@ public PostDetailVo execute(FindByIdQuery query) { loadPostPort .loadPost(query) .orElseThrow(() -> new CustomException(ErrorResponseCode.POST_NOT_FOUND)); - if (post.getIsDeleted()) { // 게시글이 삭제된 경우 예외처리 - throw new CustomException(ErrorResponseCode.POST_NOT_FOUND); - } + post.validateDeleted(); // 조회수 업데이트 post.updateViewCount(post.getViewCount() + 1); @@ -68,11 +65,17 @@ public PostDetailVo execute(FindByIdQuery query) { loadPostProductPort.loadPostProductsByPostId(FindByPostIdQuery.of(post.getId())); // 게시글 상품 별 이미지 Map, 여러 상품의 이미지 정보 한번에 조회 - Map postProductImageMap = + List postProductImages = loadPostProductImagePort.loadPostProductImagesByPostProductIds( - FindByPostProductIdsQuery.of( + FindByIdsQuery.from( postProducts.stream().map(PostProduct::getId).toList())); + Map postProductImageMap = + postProductImages.stream() + .collect( + Collectors.toMap( + PostProductImage::getPostProductId, Function.identity())); + return PostDetailVo.from( post, user, userImage, postImages, postProducts, postProductImageMap); } From c19982aaa9a6bb3380b8aa7fa8301f0fe20fea1d Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Sat, 26 Apr 2025 19:13:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=ED=94=BD=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20?= =?UTF-8?q?PostProductValidator=20=EB=A1=9C=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/post/SavePostService.java | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/ftm/server/application/service/post/SavePostService.java b/src/main/java/com/ftm/server/application/service/post/SavePostService.java index 2acfb7c..734c28b 100644 --- a/src/main/java/com/ftm/server/application/service/post/SavePostService.java +++ b/src/main/java/com/ftm/server/application/service/post/SavePostService.java @@ -11,17 +11,14 @@ 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.validator.PostProductValidator; import com.ftm.server.application.vo.post.PostInfoVo; -import com.ftm.server.common.exception.CustomException; -import com.ftm.server.common.response.enums.ErrorResponseCode; 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.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -48,7 +45,7 @@ public class SavePostService implements SavePostUseCase { public PostInfoVo execute(SavePostCommand command) { // 상품, 상품이미지 검증 - validateProductImages(command.getProducts(), command.getProductImages()); + validateProductsWithImages(command.getProducts(), command.getProductImages()); // 이미지 업로드 로직 먼저 수행 List uploadedPostImageKeys = @@ -103,32 +100,10 @@ public PostInfoVo execute(SavePostCommand command) { return PostInfoVo.from(post); } - private void validateProductImages( + private void validateProductsWithImages( List products, List productImages) { - - List imageIndexes = - products.stream() - .map(SavePostProductCommand::getImageIndex) - .filter(index -> index > 0) - .toList(); - - // 중복된 이미지 인덱스 검증 - Set seen = new HashSet<>(); - for (int index : imageIndexes) { - if (!seen.add(index)) { - log.warn("중복된 imageIndex : index={}", index); - throw new CustomException(ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING); - } - } - - // 이미지와 매핑된 상품 정보와 요청한 상품 이미지의 개수가 다를 경우 - // 중복된 imageIndex, 업로드할 이미지가 이미지와 매핑된 상품 정보 개수보다 많을 경우, 업도드할 이미지가 이미지와 매핑된 상품 정보 개수보다 적을 경우 - if (imageIndexes.size() != productImages.size()) { - log.warn( - "상품과 업로드할 상품 이미지 1:1 매핑 실패 : 이미지와 매핑된 상품 개수={}, 업로드할 이미지 개수={}", - imageIndexes.size(), - productImages.size()); - throw new CustomException(ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING); - } + PostProductValidator.validateImageIndexRange(products, productImages); + PostProductValidator.validateImageIndexDuplication(products); + PostProductValidator.validateOneToOneImageProductMapping(products, productImages); } } From ddaded3aa17fbb49b75eac63fd5552f4d74e7537 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Sat, 26 Apr 2025 19:20:14 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=EC=9C=A0=EC=A0=80=ED=94=BD=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/ftm/server/BaseTest.java | 26 +- .../grooming/SubmitGroomingTestsTest.java | 8 +- .../com/ftm/server/post/UpdatePostTest.java | 831 ++++++++++++++++++ 3 files changed, 860 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/ftm/server/post/UpdatePostTest.java diff --git a/src/test/java/com/ftm/server/BaseTest.java b/src/test/java/com/ftm/server/BaseTest.java index d347841..1fa1874 100644 --- a/src/test/java/com/ftm/server/BaseTest.java +++ b/src/test/java/com/ftm/server/BaseTest.java @@ -5,8 +5,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ftm.server.application.command.user.GeneralUserCreationCommand; +import com.ftm.server.application.port.out.persistence.auth.LoadUserForAuthPort; import com.ftm.server.application.port.out.persistence.user.SaveUserImagePort; import com.ftm.server.application.port.out.persistence.user.SaveUserPort; +import com.ftm.server.application.query.FindByEmailQuery; import com.ftm.server.domain.entity.User; import com.ftm.server.domain.entity.UserImage; import com.ftm.server.domain.enums.AgeGroup; @@ -15,6 +17,7 @@ import com.ftm.server.infrastructure.security.UserPrincipal; import groovy.util.logging.Slf4j; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -55,6 +58,7 @@ public class BaseTest { @Autowired private SaveUserPort saveUserPort; @Autowired private SaveUserImagePort saveUserImagePort; + @Autowired private LoadUserForAuthPort loadUserForAuthPort; @BeforeEach void setup(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) { @@ -80,12 +84,13 @@ protected HeadersModifyingOperationPreprocessor getModifiedHeader() { // 사용자 생성 및 저장 protected User createTestUser(String email, String password) { + String nickname = "test " + UUID.randomUUID(); User user = User.createGeneralUser( GeneralUserCreationCommand.of( email, password, - "test 사용자", + nickname, AgeGroup.FIFTIES, List.of(HashTag.PERFUME))); User testUser = saveUserPort.saveUser(user); @@ -118,4 +123,23 @@ protected MockHttpSession createUserAndLogin(String email, String password) { return session; } + + protected MockHttpSession login(String email) { + User user = loadUserForAuthPort.loadUserByEmail(FindByEmailQuery.of(email)).get(); + + // session 생성 + SecurityContext context = SecurityContextHolder.createEmptyContext(); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + UserPrincipal.of(user), + null, + List.of(new SimpleGrantedAuthority(UserRole.USER.name()))); + context.setAuthentication(auth); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + return session; + } } diff --git a/src/test/java/com/ftm/server/grooming/SubmitGroomingTestsTest.java b/src/test/java/com/ftm/server/grooming/SubmitGroomingTestsTest.java index 4c6bd7d..9abb425 100644 --- a/src/test/java/com/ftm/server/grooming/SubmitGroomingTestsTest.java +++ b/src/test/java/com/ftm/server/grooming/SubmitGroomingTestsTest.java @@ -115,13 +115,13 @@ private GroomingTestSubmissionRequest getRequest() { new GroomingTestSubmissionRequest.SubmittedQuestion( 1L, "BEAUTY", List.of(1L, 2L, 3L)), new GroomingTestSubmissionRequest.SubmittedQuestion( - 6L, "HYGIENE", List.of(16L)), + 6L, "HYGIENE", List.of(18L)), new GroomingTestSubmissionRequest.SubmittedQuestion( - 10L, "HAIR", List.of(28L)), + 10L, "HAIR", List.of(32L)), new GroomingTestSubmissionRequest.SubmittedQuestion( - 15L, "WORKOUT", List.of(41L, 42L, 43L, 44L)), + 15L, "WORKOUT", List.of(46L, 47L, 48L)), new GroomingTestSubmissionRequest.SubmittedQuestion( - 18L, "FASHION", List.of(61L))); + 18L, "FASHION", List.of(67L, 68L))); return new GroomingTestSubmissionRequest(submissions); } diff --git a/src/test/java/com/ftm/server/post/UpdatePostTest.java b/src/test/java/com/ftm/server/post/UpdatePostTest.java new file mode 100644 index 0000000..0aad322 --- /dev/null +++ b/src/test/java/com/ftm/server/post/UpdatePostTest.java @@ -0,0 +1,831 @@ +package com.ftm.server.post; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +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.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +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.adapter.in.web.post.dto.request.UpdatePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.UpdatePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.in.post.SavePostUseCase; +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.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterCommitExecutorPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.query.FindByIdsQuery; +import com.ftm.server.application.query.FindByPostIdQuery; +import com.ftm.server.application.vo.post.PostInfoVo; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.entity.PostProduct; +import com.ftm.server.domain.entity.PostProductImage; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.enums.GroomingCategory; +import com.ftm.server.domain.enums.HashTag; +import java.nio.charset.StandardCharsets; +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.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.mock.web.MockMultipartFile; +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.restdocs.request.RequestPartDescriptor; +import org.springframework.restdocs.snippet.Attributes; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Transactional; + +public class UpdatePostTest extends BaseTest { + + @Autowired private UpdatePostProductImagePort updatePostProductImagePort; + @Autowired private LoadPostProductImagePort loadPostProductImagePort; + @Autowired private LoadPostProductPort loadPostProductPort; + @Autowired private SavePostUseCase savePostUseCase; + @MockitoSpyBean private S3PostImageUploadPort s3PostImageUploadPort; + @MockitoSpyBean private S3PostProductImageUploadPort s3PostProductImageUploadPort; + @MockitoSpyBean private S3ImageDeletePort s3ImageDeletePort; + @MockitoSpyBean private AfterCommitExecutorPort afterCommitExecutorPort; + @MockitoSpyBean private AfterRollbackExecutorPort afterRollbackExecutorPort; + + private final ParameterDescriptor pathParametersForPostId = + parameterWithName("postId").description("게시글 ID"); + + private final List requestPartUpdatePost = + List.of( + partWithName("data") + .description("게시글 저장 정보") + .attributes( + new Attributes.Attribute("content-type", "application/json")), + partWithName("postImageFiles") + .optional() + .description("새로 추가할 게시글 이미지 파일 리스트 (List 형태, 순서를 보장)") + .attributes(new Attributes.Attribute("content-type", "image/*")), + partWithName("productImageFiles") + .optional() + .description("새로 추가할 상품 이미지 파일 리스트 (List 형태, 순서를 보장)") + .attributes(new Attributes.Attribute("content-type", "image/*"))); + + private final List requestPartFieldUpdatePost = + List.of( + fieldWithPath("title").type(STRING).optional().description("게시글 제목"), + fieldWithPath("groomingCategory") + .type(STRING) + .optional() + .description("게시글 그루밍 분야"), + fieldWithPath("hashTags[]").type(ARRAY).optional().description("게시글 해시태그 목록"), + fieldWithPath("content").type(STRING).optional().description("게시글 내용"), + fieldWithPath("deletePostImageIds[]") + .type(ARRAY) + .optional() + .description("삭제할 게시글 이미지 ID 목록"), + fieldWithPath("deleteProductIds[]") + .type(ARRAY) + .optional() + .description("삭제할 상품 ID 목록"), + fieldWithPath("addProducts[]") + .type(ARRAY) + .optional() + .description("새로 추가할 상품 목록"), + fieldWithPath("addProducts[].imageIndex") + .type(NUMBER) + .description( + "상품 이미지와 매핑될 인덱스 (**1**부터 시작, 이미지를 등록하지 않은 상품이라면 **-1** 저장) ** 순서 중요 **"), + fieldWithPath("addProducts[].name").type(STRING).description("상품 이름"), + fieldWithPath("addProducts[].brand").type(STRING).description("상품 브랜드"), + fieldWithPath("addProducts[].hashtags[]") + .type(ARRAY) + .optional() + .description("상품 해시태그 목록"), + fieldWithPath("updateProducts[]") + .type(ARRAY) + .optional() + .description("수정할 상품 목록"), + fieldWithPath("updateProducts[].id").type(NUMBER).description("수정할 상품 ID"), + fieldWithPath("updateProducts[].name") + .type(STRING) + .optional() + .description("수정된 상품 이름"), + fieldWithPath("updateProducts[].brand") + .type(STRING) + .optional() + .description("수정된 상품 브랜드"), + fieldWithPath("updateProducts[].hashTags[]") + .type(ARRAY) + .optional() + .description("수정된 상품 해시태그 목록"), + fieldWithPath("updateProducts[].deleteProductImageId") + .type(NUMBER) + .optional() + .description("삭제할 상품 이미지 ID -> **기존 이미지를 삭제하거나, 이미지를 변경할 경우 필수**"), + fieldWithPath("updateProducts[].imageIndex") + .type(NUMBER) + .description( + "상품 이미지와 매핑될 인덱스 (**1**부터 시작, 이미지를 등록하지 않은 상품이라면 **-1** 저장) ** 순서 중요 **")); + + private final List responseFieldUpdatePost = + 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 List savedPostProductIds = List.of(); + + private ResultActions getResultActions( + MockHttpSession session, + Long postId, + MockMultipartFile data, + List postImageFiles, + List productImageFiles) + throws Exception { + MockMultipartHttpServletRequestBuilder builder = + (MockMultipartHttpServletRequestBuilder) + RestDocumentationRequestBuilders.multipart("/api/posts/{postId}", postId) + .file(data) // JSON part ("data") + .contentType(MediaType.MULTIPART_FORM_DATA) + .session(session) + .with( + request -> { + request.setMethod("PATCH"); + return request; + }); + + // 1. List 처리 (게시글 이미지 리스트) + if (postImageFiles != null && !postImageFiles.isEmpty()) { + for (MockMultipartFile file : postImageFiles) { + builder.file(file); + } + } + + // 2. Map 처리 (상품 이미지 맵) + if (productImageFiles != null && !productImageFiles.isEmpty()) { + for (MockMultipartFile file : productImageFiles) { + builder.file(file); + } + } + + return mockMvc.perform(builder); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "updatePost/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + pathParameters(pathParametersForPostId), + requestParts(requestPartUpdatePost), + requestPartFields("data", requestPartFieldUpdatePost), + responseFields(responseFieldUpdatePost), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("유저픽 게시글 수정 api") + .description("유저픽 게시글 수정 api 입니다.") + .responseFields(responseFieldUpdatePost) + .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()), + 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(afterCommitExecutorPort).doAfterCommit(any()); + doNothing().when(afterRollbackExecutorPort).doAfterRollback(any()); + + PostInfoVo post = + savePostUseCase.execute( + SavePostCommand.from(user.getId(), postRequest, List.of(), List.of())); + savedPostId = post.getId(); + + List products = + loadPostProductPort.loadPostProductsByPostId(FindByPostIdQuery.of(post.getId())); + savedPostProductIds = products.stream().map(PostProduct::getId).toList(); + } + + private MockMultipartFile getExampleData(UpdatePostRequest request) throws Exception { + return new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)); + } + + @Test + @Transactional + void 게시글_수정_성공() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long deletePostProductId = savedPostProductIds.get(0); + Long updatedPostProductId = savedPostProductIds.get(1); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(deletePostProductId), + List.of(new SavePostProductRequest(1, "새로운 상품 추가", "새로운 상품 추가", List.of())), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, + "상품 이름 수정", + "상품 브랜드 수정", + List.of(), + null, + 2))); + + MockMultipartFile data = getExampleData(postRequest); + List postImageFiles = + List.of( + new MockMultipartFile( + "postImageFiles", + "test_01.jpg", + IMAGE_JPEG_VALUE, + "test_01".getBytes())); + List productImageFiles = + List.of( + new MockMultipartFile( + "productImageFiles", + "test_01.jpg", + IMAGE_JPEG_VALUE, + "test_01".getBytes()), + new MockMultipartFile( + "productImageFiles", + "test_02.jpg", + IMAGE_JPEG_VALUE, + "test_02".getBytes())); + + // s3 실제 호출 대신 mock 대입 + doReturn(List.of("posts/test_01.jpg")) + .when(s3PostImageUploadPort) + .uploadImages(new ArrayList<>(postImageFiles)); + doReturn(List.of("products/test_01.jpg", "products/test_02.jpg")) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(productImageFiles)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, postImageFiles, productImageFiles); + + // then + resultActions.andExpect(status().isOk()).andDo(print()); + + // document + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 게시글_수정_실패1() throws Exception { + // given + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of()); + MockMultipartFile data = getExampleData(postRequest); + + // when + ResultActions resultActions = + mockMvc.perform( + RestDocumentationRequestBuilders.multipart( + "/api/posts/{postId}", savedPostId) + .file(data) // JSON part ("data") + .contentType(MediaType.MULTIPART_FORM_DATA) + .with( + request -> { + request.setMethod("PATCH"); + return request; + })); + + // 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"); + + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of()); + MockMultipartFile data = getExampleData(postRequest); + + // when + ResultActions resultActions = getResultActions(session, 1000L, data, List.of(), List.of()); + + // 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("test1@gmail.com", "asdfgcx!!"); + + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of()); + MockMultipartFile data = getExampleData(postRequest); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), List.of()); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.UNAUTHORIZED_POST_ACCESS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(4)); + } + + @Test + @Transactional + void 게시글_수정_실패4() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long updatedPostProductId = savedPostProductIds.get(1); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(new SavePostProductRequest(1, "새로운 상품 추가", "새로운 상품 추가", List.of())), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, null, null, List.of(), null, 2))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + List postImageFiles = List.of(); + List productImageFiles = + List.of( + new MockMultipartFile( + "productImageFiles", + "test_01.jpg", + IMAGE_JPEG_VALUE, + "test_01".getBytes())); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(5)); + } + + @Test + @Transactional + void 게시글_수정_실패5() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long updatedPostProductId = savedPostProductIds.get(1); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(new SavePostProductRequest(1, "새로운 상품 추가", "새로운 상품 추가", List.of())), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, null, null, List.of(), null, 2))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + List postImageFiles = + List.of( + new MockMultipartFile( + "postImageFiles", + "test_01.txt", + TEXT_PLAIN_VALUE, + "test_01".getBytes())); + List productImageFiles = List.of(); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.INVALID_IMAGE_FORMAT.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(6)); + } + + @Test + @Transactional + void 게시글_수정_실패6() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long updatedPostProductId = savedPostProductIds.get(1); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(new SavePostProductRequest(1, "새로운 상품 추가", "새로운 상품 추가", List.of())), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, null, null, List.of(), null, 2))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + List postImageFiles = + List.of( + new MockMultipartFile( + "postImageFiles", + "test_01.jpg", + MediaType.IMAGE_JPEG_VALUE, + "test_01".getBytes())); + List productImageFiles = List.of(); + + doThrow(new CustomException(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE)) + .when(s3PostImageUploadPort) + .uploadImages(any()); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(7)); + } + + @Test + @Transactional + void 게시글_수정_실패7() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(1000L), + List.of(), + List.of(), + List.of()); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), List.of()); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.UNAUTHORIZED_POST_IMAGE_ACCESS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(8)); + } + + @Test + @Transactional + void 게시글_수정_실패8() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(1000L), + List.of(), + List.of()); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), List.of()); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.UNAUTHORIZED_POST_PRODUCT_ACCESS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(9)); + } + + @Test + @Transactional + void 게시글_수정_실패9() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long updatedPostProductId = savedPostProductIds.get(0); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, + "상품 이름 수정", + null, + List.of(), + 1000L, + -1))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), List.of()); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.UNAUTHORIZED_POST_PRODUCT_IMAGE_ACCESS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(10)); + } + + @Test + @Transactional + void 게시글_수정_실패10() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + Long updatedPostProductId = savedPostProductIds.get(0); + List postProductImages = + loadPostProductImagePort.loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(savedPostProductIds)); + Long deletedPostProductImageId = postProductImages.get(0).getId(); + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of( + UpdatePostProductRequest.of( + updatedPostProductId, + "상품 이름 수정", + null, + List.of(), + deletedPostProductImageId, + -1))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), List.of()); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.CANNOT_DELETE_DEFAULT_IMAGE + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(11)); + } + + @Test + @Transactional + void 게시글_수정_실패11() throws Exception { + // given + MockHttpSession session = login("test@gmail.com"); + + List productImages = + loadPostProductImagePort.loadPostProductImagesByPostProductIds( + FindByIdsQuery.from(savedPostProductIds)); + PostProductImage updateProductImage = productImages.get(0); + updateProductImage.updateObjectKey("products/test_01.jpg"); + + updatePostProductImagePort.updatePostProductImages(List.of(updateProductImage)); + + UpdatePostRequest postRequest = + UpdatePostRequest.of( + "게시글 제목 수정", + null, + List.of(), + "게시글 내용 수정", + List.of(), + List.of(), + List.of(), + List.of( + UpdatePostProductRequest.of( + updateProductImage.getPostProductId(), + null, + null, + List.of(), + null, + 1))); + + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + List productImageFiles = + List.of( + new MockMultipartFile( + "productImageFiles", + "test_01.jpg", + IMAGE_JPEG_VALUE, + "test_01".getBytes())); + + doReturn(List.of("products/test_01.jpg")) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(productImageFiles)); + + // when + ResultActions resultActions = + getResultActions(session, savedPostId, data, List.of(), productImageFiles); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.POST_PRODUCT_IMAGE_ALREADY_EXISTS + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(12)); + } +} From 5abfac6358c6ebb7b82c1163f6586890c865cf18 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Sat, 26 Apr 2025 19:20:29 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EC=9C=A0=EC=A0=80=ED=94=BD=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/post-api.adoc | 98 ++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc index 9e1c462..4ac823e 100644 --- a/src/docs/asciidoc/post-api.adoc +++ b/src/docs/asciidoc/post-api.adoc @@ -67,4 +67,100 @@ include::{snippetsDir}/loadPostDetail/1/response-fields.adoc[] 실패 1. 존재하지 않는 게시글이거나, 삭제된 게시글인 경우 -include::{snippetsDir}/loadPostDetail/2/http-response.adoc[] \ No newline at end of file +include::{snippetsDir}/loadPostDetail/2/http-response.adoc[] + +--- + +=== **3. 게시글 수정** + +유저픽 게시글 수정 api 입니다. + +수정한 필드에 대해서만 요청 데이터에 담아서 요청하면 됩니다. + +다음과 같이 수정 요청 가능합니다. + +- 기존 게시글 내용 수정 + +- 새로운 게시글 이미지 추가 + +- 기존 게시글 이미지 삭제 + +- 새로운 상품 추가 + +- 기존 상품 내용 수정 + +- 기존 상품 삭제 + +- 새로운 상품 이미지 추가 + +- 기존 상품 이미지 삭제 + +기존 상품의 이미지를 변경할 때, 기존 이미지를 삭제한다면 `deleteProductImageId` 필드에 꼭 상품 이미지 ID를 기재하고, 새로운 이미지를 등록하면 `imageIndex` 필드에 관련 상품 이미지 파일의 인덱스를 꼭 기재해야 합니다. + +`imageIndex` 관련 내용은 게시글 저장 API와 동일합니다. + +==== Request +include::{snippetsDir}/updatePost/1/curl-request.adoc[] + +==== Request Path Parameters +include::{snippetsDir}/updatePost/1/path-parameters.adoc[] + +==== Request Parts +include::{snippetsDir}/updatePost/1/request-parts.adoc[] + +==== Request Parts : **data** - Detail Fields +include::{snippetsDir}/updatePost/1/request-part-data-fields.adoc[] + +==== 성공 Response +include::{snippetsDir}/updatePost/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/updatePost/1/response-fields.adoc[] + +==== 실패 Response +실패 1. 인증되지 않은 유저일 경우 + +include::{snippetsDir}/updatePost/2/http-response.adoc[] + +실패 2. 존재하지 않는 게시글일 경우 + +include::{snippetsDir}/updatePost/3/http-response.adoc[] + +실패 3. 게시글을 조작할 권한이 없을 경우 (요청한 유저 != 작성한 유저) + +include::{snippetsDir}/updatePost/4/http-response.adoc[] + + +실패 4. 이미지와 상품 정보가 매핑되지 않을 경우 + +- 상품 정보 imageIndex 중복 + +- 이미지 개수와 상품 개수 불일치 (이미지를 등록하지 않은 상품을 제외한 개수로 비교) + + +include::{snippetsDir}/updatePost/5/http-response.adoc[] + + +실패 5. 요청한 파일 목록 검증에 실패할 경우 (이미지 파일 X, 사이즈) + +include::{snippetsDir}/updatePost/6/http-response.adoc[] + +실패 6. 이미지 파일 업로드에 실패할 경우 + +include::{snippetsDir}/updatePost/7/http-response.adoc[] + +실패 7. 요청 데이터의 게시글 이미지가 해당 게시글의 이미지가 아닐 경우 + +- 게시글 이미지 삭제 + +- deletePostImageIds + + +include::{snippetsDir}/updatePost/8/http-response.adoc[] + +실패 8. 요청 데이터의 상품 정보가 해당 게시글의 상품이 아닐 경우 + +- 상품 삭제 + +- 상품 업데이트 + +- deleteProductIds, updateProducts.id + + +include::{snippetsDir}/updatePost/9/http-response.adoc[] + +실패 9. 요청 데이터의 상품 이미지 정보가 해당 상품의 이미지가 아닐 경우 + +- 상품 이미지 변경 시 삭제 + +- updateProducts.deleteProductImageId + + +include::{snippetsDir}/updatePost/10/http-response.adoc[] + +실패 10. 기본 상품 이미지 삭제를 요청할 경우 + +include::{snippetsDir}/updatePost/11/http-response.adoc[] + +실패 11. 기존 상품 이미지가 존재하지만, 새로운 이미지 등록을 요청할 경우 + +include::{snippetsDir}/updatePost/12/http-response.adoc[] + + + +