From 10c24ff0388e86c71f1672f627d45c60f212d062 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Wed, 16 Apr 2025 23:59:08 +0900 Subject: [PATCH 1/3] =?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=A0=80=EC=9E=A5=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 --- .../adapter/in/web/post/controller/.gitkeep | 0 .../post/controller/SavePostController.java | 40 ++++++ .../server/adapter/in/web/post/dto/.gitkeep | 0 .../dto/request/SavePostProductRequest.java | 17 +++ .../web/post/dto/request/SavePostRequest.java | 19 +++ .../out/persistence/adapter/post/.gitkeep | 0 .../post/PostDomainPersistenceAdapter.java | 122 ++++++++++++++++ .../out/persistence/model/PostJpaEntity.java | 1 - .../adapter/out/s3/S3ImageDeleteAdapter.java | 12 ++ .../adapter/out/s3/S3ImageUploadAdapter.java | 73 ++++++++-- .../out/s3/S3PostImageUploadAdapter.java | 23 +++ .../s3/S3PostProductImageUploadAdapter.java | 25 ++++ .../out/s3/S3UserImageUploadAdapter.java | 2 +- .../command/post/SavePostCommand.java | 49 +++++++ .../command/post/SavePostProductCommand.java | 40 ++++++ .../server/application/port/in/post/.gitkeep | 0 .../port/in/post/SavePostUseCase.java | 10 ++ .../port/out/persistence/post/.gitkeep | 0 .../persistence/post/SavePostImagePort.java | 11 ++ .../out/persistence/post/SavePostPort.java | 10 ++ .../post/SavePostProductImagePort.java | 11 ++ .../persistence/post/SavePostProductPort.java | 11 ++ .../port/out/s3/S3ImageDeletePort.java | 4 + .../port/out/s3/S3ImageUploadPort.java | 5 +- .../port/out/s3/S3PostImageUploadPort.java | 11 ++ .../out/s3/S3PostProductImageUploadPort.java | 11 ++ .../port/out/s3/S3UserImageUploadPort.java | 2 +- .../server/application/service/post/.gitkeep | 0 .../service/post/SavePostService.java | 131 ++++++++++++++++++ .../common/consts/PropertiesHolder.java | 12 +- .../response/enums/ErrorResponseCode.java | 8 +- .../com/ftm/server/domain/entity/Post.java | 14 ++ .../ftm/server/domain/entity/PostImage.java | 10 ++ .../ftm/server/domain/entity/PostProduct.java | 10 ++ .../domain/entity/PostProductImage.java | 13 ++ src/main/resources/application-storage.yml | 8 +- 36 files changed, 692 insertions(+), 23 deletions(-) delete mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/controller/.gitkeep create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/controller/SavePostController.java delete mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/.gitkeep create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostProductRequest.java create mode 100644 src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostRequest.java delete mode 100644 src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/.gitkeep create mode 100644 src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java create mode 100644 src/main/java/com/ftm/server/adapter/out/s3/S3PostImageUploadAdapter.java create mode 100644 src/main/java/com/ftm/server/adapter/out/s3/S3PostProductImageUploadAdapter.java create mode 100644 src/main/java/com/ftm/server/application/command/post/SavePostCommand.java create mode 100644 src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java delete mode 100644 src/main/java/com/ftm/server/application/port/in/post/.gitkeep create mode 100644 src/main/java/com/ftm/server/application/port/in/post/SavePostUseCase.java delete mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/.gitkeep create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostImagePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostPort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductImagePort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductPort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/s3/S3PostImageUploadPort.java create mode 100644 src/main/java/com/ftm/server/application/port/out/s3/S3PostProductImageUploadPort.java delete mode 100644 src/main/java/com/ftm/server/application/service/post/.gitkeep create mode 100644 src/main/java/com/ftm/server/application/service/post/SavePostService.java diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/.gitkeep b/src/main/java/com/ftm/server/adapter/in/web/post/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/controller/SavePostController.java b/src/main/java/com/ftm/server/adapter/in/web/post/controller/SavePostController.java new file mode 100644 index 0000000..53d5ab9 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/controller/SavePostController.java @@ -0,0 +1,40 @@ +package com.ftm.server.adapter.in.web.post.controller; + +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.port.in.post.SavePostUseCase; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import jakarta.validation.Valid; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class SavePostController { + + private final SavePostUseCase savePostUseCase; + + @PostMapping("/api/posts") + public ResponseEntity> savePost( + @RequestPart(value = "data") @Valid SavePostRequest request, + @RequestPart(value = "postImageFiles", required = false) + List postImageFiles, + @RequestPart(value = "productImageFiles", required = false) + List productImageFiles, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + savePostUseCase.execute( + SavePostCommand.from( + userPrincipal.getId(), request, postImageFiles, productImageFiles)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(SuccessResponseCode.CREATED)); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/.gitkeep b/src/main/java/com/ftm/server/adapter/in/web/post/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostProductRequest.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostProductRequest.java new file mode 100644 index 0000000..cdd7e06 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostProductRequest.java @@ -0,0 +1,17 @@ +package com.ftm.server.adapter.in.web.post.dto.request; + +import com.ftm.server.domain.enums.HashTag; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SavePostProductRequest { + + private int imageIndex; + @NotEmpty private String name; + private String brand; + private List hashtags; +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostRequest.java b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostRequest.java new file mode 100644 index 0000000..a0b457c --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/post/dto/request/SavePostRequest.java @@ -0,0 +1,19 @@ +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 jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SavePostRequest { + + @NotEmpty private String title; + private GroomingCategory groomingCategory; + private List hashtags; + @NotEmpty private String content; + private List products; +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/.gitkeep b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/.gitkeep deleted file mode 100644 index e69de29..0000000 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 new file mode 100644 index 0000000..9d9b8fa --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/post/PostDomainPersistenceAdapter.java @@ -0,0 +1,122 @@ +package com.ftm.server.adapter.out.persistence.adapter.post; + +import com.ftm.server.adapter.out.persistence.mapper.PostImageMapper; +import com.ftm.server.adapter.out.persistence.mapper.PostMapper; +import com.ftm.server.adapter.out.persistence.mapper.PostProductImageMapper; +import com.ftm.server.adapter.out.persistence.mapper.PostProductMapper; +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.SavePostImagePort; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductImagePort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductPort; +import com.ftm.server.common.annotation.Adapter; +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.List; +import lombok.RequiredArgsConstructor; + +@Adapter +@RequiredArgsConstructor +public class PostDomainPersistenceAdapter + implements SavePostPort, SavePostImagePort, SavePostProductPort, SavePostProductImagePort { + + private final PostRepository postRepository; + private final PostImageRepository postImageRepository; + private final PostProductRepository postProductRepository; + private final PostProductImageRepository postProductImageRepository; + private final UserRepository userRepository; + + private final PostMapper postMapper; + private final PostImageMapper postImageMapper; + private final PostProductMapper postProductMapper; + private final PostProductImageMapper postProductImageMapper; + + @Override + public Post savePost(Post post) { + UserJpaEntity userJpaEntity = + userRepository + .findById(post.getUserId()) + .orElseThrow(() -> CustomException.USER_NOT_FOUND); + PostJpaEntity postJpaEntity = + postRepository.save(postMapper.toJpaEntity(post, userJpaEntity)); + + return postMapper.toDomainEntity(postJpaEntity); + } + + @Override + public List savePostImages(List postImages) { + List postImageJpaEntities = + postImages.stream() + .map( + postImage -> { + PostJpaEntity postJpaEntity = + postRepository + .findById(postImage.getPostId()) + .orElseThrow( + () -> + new CustomException( + ErrorResponseCode + .POST_NOT_FOUND)); + + return postImageMapper.toJpaEntity(postImage, postJpaEntity); + }) + .toList(); + + return postImageRepository.saveAll(postImageJpaEntities).stream() + .map(postImageMapper::toDomainEntity) + .toList(); + } + + @Override + public List savePostProducts(List postProducts) { + List productJpaEntities = + postProducts.stream() + .map( + postProduct -> { + PostJpaEntity postJpaEntity = + postRepository + .findById(postProduct.getPostId()) + .orElseThrow( + () -> + new CustomException( + ErrorResponseCode + .POST_NOT_FOUND)); + return postProductMapper.toJpaEntity( + postProduct, postJpaEntity); + }) + .toList(); + + return postProductRepository.saveAll(productJpaEntities).stream() + .map(postProductMapper::toDomainEntity) + .toList(); + } + + @Override + public List savePostProductImages(List productImages) { + List postProductImageJpaEntities = + productImages.stream() + .map( + postProductImage -> { + PostProductJpaEntity postProductJpaEntity = + postProductRepository + .findById(postProductImage.getPostProductId()) + .orElseThrow( + () -> + new CustomException( + ErrorResponseCode + .POST_PRODUCT_NOT_FOUND)); + return postProductImageMapper.toJpaEntity( + postProductImage, postProductJpaEntity); + }) + .toList(); + + return postProductImageRepository.saveAll(postProductImageJpaEntities).stream() + .map(postProductImageMapper::toDomainEntity) + .toList(); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostJpaEntity.java index 9f57156..a0399ca 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/PostJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/PostJpaEntity.java @@ -31,7 +31,6 @@ public class PostJpaEntity extends BaseTimeJpaEntity { @Column(nullable = false) private String title; - @Lob @Column(nullable = false) private String content; diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java index 7b86ab6..da31e4e 100644 --- a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java @@ -2,6 +2,7 @@ import com.ftm.server.application.port.out.s3.S3ImageDeletePort; import com.ftm.server.common.annotation.Adapter; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import software.amazon.awssdk.services.s3.S3Client; @@ -24,4 +25,15 @@ public void deleteImage(String objectKey) { s3Client.deleteObject(deleteRequest); } + + @Override + public void deleteImages(List objectKeys) { + if (objectKeys == null || objectKeys.isEmpty()) return; + for (String objectKey : objectKeys) { + DeleteObjectRequest deleteRequest = + DeleteObjectRequest.builder().bucket(bucket).key(objectKey).build(); + + s3Client.deleteObject(deleteRequest); + } + } } diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java index dcd9892..da910cb 100644 --- a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java @@ -6,6 +6,8 @@ import com.ftm.server.common.exception.CustomException; import com.ftm.server.common.response.enums.ErrorResponseCode; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,25 +32,45 @@ public class S3ImageUploadAdapter implements S3ImageUploadPort { private String bucket; @Override - public String updateImage(MultipartFile imageFile, String dirName) { + public String uploadImage(MultipartFile imageFile, String dirName) { - // 이미지 검증 : 사이즈, 타입, empty 여부 - String type = null; - try { - type = tikaFileTypeDetectionPort.detectFileType(imageFile); // 이미지 타입 추출 - } catch (IOException e) { - throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); - } - if (imageFile == null - || imageFile.isEmpty() - || (float) imageFile.getSize() / (1024.0 * 1024.0) > (float) 10 - || !type.startsWith("image/")) { - throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); - } + // 사전 이미지 검증 + validateImageFile(imageFile); String originalFileName = imageFile.getOriginalFilename(); String fileName = dirName + "/" + UUID.randomUUID() + "_" + originalFileName; + // 이미지 업로드 + 재시도 + uploadImageWithRetry(fileName, imageFile); + + return fileName; + } + + @Override + public List uploadImages(List imageFiles, String dirName) { + List uploadedFileNames = new ArrayList<>(); + + // 사전 이미지 검증 + for (MultipartFile imageFile : imageFiles) { + validateImageFile(imageFile); + } + + // 이미지 업로드 + for (MultipartFile imageFile : imageFiles) { + String originalFileName = imageFile.getOriginalFilename(); + String fileName = dirName + "/" + UUID.randomUUID() + "_" + originalFileName; + + // 이미지 업로드 + 재시도 + uploadImageWithRetry(fileName, imageFile); + uploadedFileNames.add(fileName); + } + + return uploadedFileNames; + } + + // 이미지 업로드 + 재시도 + private void uploadImageWithRetry(String fileName, MultipartFile imageFile) { + // S3 이미지 요청 객체 PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucket) @@ -66,7 +88,8 @@ public String updateImage(MultipartFile imageFile, String dirName) { putObjectRequest, RequestBody.fromInputStream( imageFile.getInputStream(), imageFile.getSize())); - return fileName; + + return; } catch (AwsServiceException | SdkClientException | IOException e) { attempt++; log.warn( @@ -79,6 +102,7 @@ public String updateImage(MultipartFile imageFile, String dirName) { // 재시도 끝까지 실패하면 예외 던지기 throw new CustomException(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE); } + try { Thread.sleep(1000L * attempt); // 1초, 2초, 3초 점진적 딜레이 } catch (InterruptedException ie) { @@ -88,4 +112,23 @@ public String updateImage(MultipartFile imageFile, String dirName) { } } } + + // 이미지 검증 : 사이즈, 타입, empty 여부 + private void validateImageFile(MultipartFile imageFile) { + if (imageFile == null || imageFile.isEmpty()) { + throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); + } + + String type; + try { + type = tikaFileTypeDetectionPort.detectFileType(imageFile); // 이미지 타입 추출 + } catch (IOException e) { + throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); + } + + if ((float) imageFile.getSize() / (1024.0 * 1024.0) > (float) 10 + || !type.startsWith("image/")) { + throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); + } + } } diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3PostImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3PostImageUploadAdapter.java new file mode 100644 index 0000000..d890019 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3PostImageUploadAdapter.java @@ -0,0 +1,23 @@ +package com.ftm.server.adapter.out.s3; + +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.common.annotation.Adapter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; + +@Adapter +@RequiredArgsConstructor +public class S3PostImageUploadAdapter implements S3PostImageUploadPort { + + @Value("${aws.s3.path.post}") + private String path; + + private final S3ImageUploadAdapter s3ImageUploadAdapter; + + @Override + public List uploadImages(List imageFiles) { + return s3ImageUploadAdapter.uploadImages(imageFiles, path); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3PostProductImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3PostProductImageUploadAdapter.java new file mode 100644 index 0000000..0d61904 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3PostProductImageUploadAdapter.java @@ -0,0 +1,25 @@ +package com.ftm.server.adapter.out.s3; + +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.common.annotation.Adapter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Adapter +@RequiredArgsConstructor +public class S3PostProductImageUploadAdapter implements S3PostProductImageUploadPort { + + @Value("${aws.s3.path.product}") + private String path; + + private final S3ImageUploadAdapter s3ImageUploadAdapter; + + @Override + public List uploadImages(List imageFiles) { + return s3ImageUploadAdapter.uploadImages(imageFiles, path); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java index bea6a5e..44c1797 100644 --- a/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java @@ -18,6 +18,6 @@ public class S3UserImageUploadAdapter implements S3UserImageUploadPort { @Override public String uploadImage(MultipartFile imageFile) { - return s3ImageUploadPort.updateImage(imageFile, path); + return s3ImageUploadPort.uploadImage(imageFile, path); } } diff --git a/src/main/java/com/ftm/server/application/command/post/SavePostCommand.java b/src/main/java/com/ftm/server/application/command/post/SavePostCommand.java new file mode 100644 index 0000000..63febe4 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/SavePostCommand.java @@ -0,0 +1,49 @@ +package com.ftm.server.application.command.post; + +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +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 SavePostCommand { + + private final Long userId; + private final String title; + private final GroomingCategory groomingCategory; + private final HashTag[] hashTags; + private final String content; + private final List postImages; + private final List products; + private final List productImages; + + private SavePostCommand( + Long userId, + SavePostRequest request, + List postImages, + List products, + List productImages) { + this.userId = userId; + this.title = request.getTitle(); + this.groomingCategory = getGroomingCategory(); + this.hashTags = request.getHashtags().toArray(new HashTag[0]); + this.content = request.getContent(); + this.postImages = postImages; + this.products = products; + this.productImages = productImages; + } + + public static SavePostCommand from( + Long userId, + SavePostRequest request, + List postImages, + List productImages) { + if (postImages == null) postImages = List.of(); + if (productImages == null) productImages = List.of(); + List products = + request.getProducts().stream().map(SavePostProductCommand::from).toList(); + return new SavePostCommand(userId, request, postImages, products, productImages); + } +} 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 new file mode 100644 index 0000000..a80af01 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/post/SavePostProductCommand.java @@ -0,0 +1,40 @@ +package com.ftm.server.application.command.post; + +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.domain.enums.HashTag; +import lombok.Getter; + +@Getter +public class SavePostProductCommand { + + private final Long postId; // null 가능 (저장 전) + private final String name; + private final String brand; + private final HashTag[] hashTags; + private final int imageIndex; + + private SavePostProductCommand( + Long postId, String name, String brand, HashTag[] hashTags, int imageIndex) { + this.postId = postId; + this.brand = name; + this.name = brand; + this.hashTags = hashTags; + this.imageIndex = imageIndex; + } + + // 생성 팩토리: 초기 상태 (postId 없음) + public static SavePostProductCommand from(SavePostProductRequest request) { + return new SavePostProductCommand( + null, + request.getName(), + request.getBrand(), + request.getHashtags().toArray(new HashTag[0]), + request.getImageIndex()); + } + + // postId 추가한 새로운 인스턴스 반환 + public SavePostProductCommand withPostId(Long postId) { + return new SavePostProductCommand( + postId, this.name, this.brand, this.hashTags, this.imageIndex); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/post/.gitkeep b/src/main/java/com/ftm/server/application/port/in/post/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/application/port/in/post/SavePostUseCase.java b/src/main/java/com/ftm/server/application/port/in/post/SavePostUseCase.java new file mode 100644 index 0000000..ca59d35 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/post/SavePostUseCase.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.in.post; + +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.common.annotation.UseCase; + +@UseCase +public interface SavePostUseCase { + + void execute(SavePostCommand command); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/.gitkeep b/src/main/java/com/ftm/server/application/port/out/persistence/post/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostImagePort.java new file mode 100644 index 0000000..366d895 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostImagePort.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 SavePostImagePort { + + List savePostImages(List postImages); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostPort.java new file mode 100644 index 0000000..e2069c0 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostPort.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.out.persistence.post; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.Post; + +@Port +public interface SavePostPort { + + Post savePost(Post post); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductImagePort.java new file mode 100644 index 0000000..925dcbb --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductImagePort.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 SavePostProductImagePort { + + List savePostProductImages(List productImages); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductPort.java new file mode 100644 index 0000000..b10034c --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/post/SavePostProductPort.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 SavePostProductPort { + + List savePostProducts(List postProducts); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java index bb373f0..498f2ca 100644 --- a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java @@ -1,5 +1,9 @@ package com.ftm.server.application.port.out.s3; +import java.util.List; + public interface S3ImageDeletePort { void deleteImage(String objectKey); + + void deleteImages(List objectKeys); } diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java index cdb380c..e1cc037 100644 --- a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java @@ -1,8 +1,11 @@ package com.ftm.server.application.port.out.s3; +import java.util.List; import org.springframework.web.multipart.MultipartFile; public interface S3ImageUploadPort { - String updateImage(MultipartFile imageFile, String dirName); + String uploadImage(MultipartFile imageFile, String dirName); + + List uploadImages(List imageFiles, String dirName); } diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3PostImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3PostImageUploadPort.java new file mode 100644 index 0000000..341fd3e --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3PostImageUploadPort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.s3; + +import com.ftm.server.common.annotation.Port; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +@Port +public interface S3PostImageUploadPort { + + List uploadImages(List imageFiles); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3PostProductImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3PostProductImageUploadPort.java new file mode 100644 index 0000000..5a83572 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3PostProductImageUploadPort.java @@ -0,0 +1,11 @@ +package com.ftm.server.application.port.out.s3; + +import com.ftm.server.common.annotation.Port; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +@Port +public interface S3PostProductImageUploadPort { + + List uploadImages(List imageFiles); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java index e98395f..d36a826 100644 --- a/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java @@ -4,5 +4,5 @@ public interface S3UserImageUploadPort { - public String uploadImage(MultipartFile imageFile); + String uploadImage(MultipartFile imageFile); } diff --git a/src/main/java/com/ftm/server/application/service/post/.gitkeep b/src/main/java/com/ftm/server/application/service/post/.gitkeep deleted file mode 100644 index e69de29..0000000 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 new file mode 100644 index 0000000..b9eca41 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/post/SavePostService.java @@ -0,0 +1,131 @@ +package com.ftm.server.application.service.post; + +import com.ftm.server.application.command.post.SavePostCommand; +import com.ftm.server.application.command.post.SavePostProductCommand; +import com.ftm.server.application.port.in.post.SavePostUseCase; +import com.ftm.server.application.port.out.persistence.post.SavePostImagePort; +import com.ftm.server.application.port.out.persistence.post.SavePostPort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductImagePort; +import com.ftm.server.application.port.out.persistence.post.SavePostProductPort; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.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; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SavePostService implements SavePostUseCase { + + private final SavePostPort savePostPort; + private final S3PostImageUploadPort s3PostImageUploadPort; + private final SavePostImagePort savePostImagePort; + private final SavePostProductPort savePostProductPort; + private final S3PostProductImageUploadPort s3PostProductImageUploadPort; + private final SavePostProductImagePort savePostProductImagePort; + private final S3ImageDeletePort s3ImageDeletePort; + + private final AfterRollbackExecutorPort afterRollbackExecutorPort; + + @Override + @Transactional + public void execute(SavePostCommand command) { + + // 상품, 상품이미지 검증 + validateProductImages(command.getProducts(), command.getProductImages()); + + // 이미지 업로드 로직 먼저 수행 + List uploadedPostImageKeys = + s3PostImageUploadPort.uploadImages(command.getPostImages()); + List uploadedPostProductImageKeys = + s3PostProductImageUploadPort.uploadImages(command.getProductImages()); + + // 롤백 시 업로드된 이미지 삭제 저장 + afterRollbackExecutorPort.doAfterRollback( + () -> { + s3ImageDeletePort.deleteImages(uploadedPostImageKeys); + s3ImageDeletePort.deleteImages(uploadedPostProductImageKeys); + }); + + // 게시글 저장 + Post post = savePostPort.savePost(Post.create(command)); + + // 게시글 이미지 목록 저장 + List postImages = + uploadedPostImageKeys.isEmpty() + ? List.of(PostImage.createDefault(post.getId())) + : uploadedPostImageKeys.stream() + .map(key -> PostImage.create(post.getId(), key)) + .toList(); + savePostImagePort.savePostImages(postImages); + + // 게시글 상품 목록 저장 + List savePostProductCommands = command.getProducts(); + List postProducts = + savePostProductCommands.stream() + .map(product -> PostProduct.create(product.withPostId(post.getId()))) + .toList(); + List savedPostProducts = savePostProductPort.savePostProducts(postProducts); + + // 게시글 상품 이미지 목록 저장 + List postProductImages = new ArrayList<>(); + for (int i = 0; i < savePostProductCommands.size(); i++) { + PostProduct postProduct = savedPostProducts.get(i); + int imageIndex = savePostProductCommands.get(i).getImageIndex(); + if (imageIndex > 0) { + postProductImages.add( + PostProductImage.create( + postProduct.getId(), + uploadedPostProductImageKeys.get(imageIndex - 1))); + continue; + } + + postProductImages.add(PostProductImage.createDefault(postProduct.getId())); + } + savePostProductImagePort.savePostProductImages(postProductImages); + } + + private void validateProductImages( + 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); + } + } +} diff --git a/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java b/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java index 971ffe3..88d561b 100644 --- a/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java +++ b/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java @@ -13,12 +13,22 @@ public class PropertiesHolder { @Value("${aws.s3.default.user}") private String userDefaultImage; - public static String USER_DEFAULT_IMAGE; + @Value("${aws.s3.default.post}") + private String postDefaultImage; + + @Value("${aws.s3.default.product}") + private String productDefaultImage; + public static String CDN_PATH; + public static String USER_DEFAULT_IMAGE; + public static String POST_DEFAULT_IMAGE; + public static String PRODUCT_DEFAULT_IMAGE; @PostConstruct public void init() { CDN_PATH = cdnPathValue; USER_DEFAULT_IMAGE = userDefaultImage; + POST_DEFAULT_IMAGE = postDefaultImage; + PRODUCT_DEFAULT_IMAGE = productDefaultImage; } } 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 a5ae57d..df0c8f4 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 @@ -19,6 +19,10 @@ public enum ErrorResponseCode { HttpStatus.BAD_REQUEST, "E400_006", "유효하지 않은 그루밍 테스트 답변 정보입니다."), INVALID_IMAGE_FORMAT( HttpStatus.BAD_REQUEST, "E400_007", "유효하지 않은 이미지입니다. 포맷, 크기(최대 10MB), 존재 유무를 확인해 주세요."), + INVALID_POST_PRODUCT_IMAGE_MAPPING( + HttpStatus.BAD_REQUEST, + "E400_008", + "상품과 이미지 간의 매핑이 올바르지 않습니다. imageIndex와 이미지 수를 확인해주세요."), // 401번 NOT_AUTHENTICATED(HttpStatus.UNAUTHORIZED, "E401_001", "인증되지 않은 사용자입니다."), @@ -31,7 +35,9 @@ public enum ErrorResponseCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_001", "요청된 사용자를 찾을 수 없습니다."), USER_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_002", "요청한 유저 이미지를 찾을 수 없습니다."), GROOMING_LEVEL_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_003", "요청한 그루밍 레벨 정보를 찾을 수 없습니다."), - GROOMING_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_004", "요창한 그루밍 카테고리 정보를 찾을 수 없습니다."), + GROOMING_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_004", "요청한 그루밍 카테고리 정보를 찾을 수 없습니다."), + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_005", "요청한 게시글을 찾을 수 없습니다."), + POST_PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "E404_006", "요청한 상품을 찾을 수 없습니다."), // 409번 USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "E409_001", "이미 존재하는 사용자입니다."), 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 2017a04..bc00495 100644 --- a/src/main/java/com/ftm/server/domain/entity/Post.java +++ b/src/main/java/com/ftm/server/domain/entity/Post.java @@ -1,5 +1,6 @@ package com.ftm.server.domain.entity; +import com.ftm.server.application.command.post.SavePostCommand; import com.ftm.server.domain.enums.GroomingCategory; import com.ftm.server.domain.enums.HashTag; import java.time.LocalDateTime; @@ -79,4 +80,17 @@ public static Post of( .updatedAt(updatedAt) .build(); } + + public static Post create(SavePostCommand command) { + return Post.builder() + .userId(command.getUserId()) + .title(command.getTitle()) + .content(command.getContent()) + .groomingCategory(command.getGroomingCategory()) + .hashtags(command.getHashTags()) + .viewCount(0) + .likeCount(0) + .isDeleted(false) + .build(); + } } 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 4a2d5f7..dca99a2 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostImage.java +++ b/src/main/java/com/ftm/server/domain/entity/PostImage.java @@ -1,5 +1,7 @@ package com.ftm.server.domain.entity; +import static com.ftm.server.common.consts.PropertiesHolder.POST_DEFAULT_IMAGE; + import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -42,4 +44,12 @@ public static PostImage of( .updatedAt(updatedAt) .build(); } + + public static PostImage create(Long postId, String objectKey) { + return PostImage.builder().postId(postId).objectKey(objectKey).build(); + } + + public static PostImage createDefault(Long postId) { + return PostImage.builder().postId(postId).objectKey(POST_DEFAULT_IMAGE).build(); + } } 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 e3f646d..1b6baea 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostProduct.java +++ b/src/main/java/com/ftm/server/domain/entity/PostProduct.java @@ -1,5 +1,6 @@ package com.ftm.server.domain.entity; +import com.ftm.server.application.command.post.SavePostProductCommand; import com.ftm.server.domain.enums.HashTag; import java.time.LocalDateTime; import lombok.AccessLevel; @@ -53,4 +54,13 @@ public static PostProduct of( .updatedAt(updatedAt) .build(); } + + public static PostProduct create(SavePostProductCommand command) { + return PostProduct.builder() + .postId(command.getPostId()) + .name(command.getName()) + .brand(command.getBrand()) + .hashTags(command.getHashTags()) + .build(); + } } 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 9192665..f4e23ca 100644 --- a/src/main/java/com/ftm/server/domain/entity/PostProductImage.java +++ b/src/main/java/com/ftm/server/domain/entity/PostProductImage.java @@ -1,5 +1,7 @@ package com.ftm.server.domain.entity; +import static com.ftm.server.common.consts.PropertiesHolder.PRODUCT_DEFAULT_IMAGE; + import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -42,4 +44,15 @@ public static PostProductImage of( .updatedAt(updatedAt) .build(); } + + public static PostProductImage create(Long postProductId, String objectKey) { + return PostProductImage.builder().postProductId(postProductId).objectKey(objectKey).build(); + } + + public static PostProductImage createDefault(Long postProductId) { + return PostProductImage.builder() + .postProductId(postProductId) + .objectKey(PRODUCT_DEFAULT_IMAGE) + .build(); + } } diff --git a/src/main/resources/application-storage.yml b/src/main/resources/application-storage.yml index 9e35abc..9a037f6 100644 --- a/src/main/resources/application-storage.yml +++ b/src/main/resources/application-storage.yml @@ -8,6 +8,10 @@ aws: bucket-name: ${S3_BUCKET_NAME} region: ${AWS_REGION} default: - user : "users/default-image.jpg" + user: "users/default-image.jpg" + post: "posts/default-image.jpg" + product: "products/default-image.jpg" path: - user: "users" \ No newline at end of file + user: "users" + post: "posts" + product: "products" \ No newline at end of file From 5babbbfa924d1d633670f5e98df6f331f2065ece Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Thu, 17 Apr 2025 17:34:45 +0900 Subject: [PATCH 2/3] =?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=A0=80=EC=9E=A5=20=ED=85=8C?= =?UTF-8?q?=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 --- .../com/ftm/server/post/SavePostTest.java | 434 ++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/test/java/com/ftm/server/post/SavePostTest.java diff --git a/src/test/java/com/ftm/server/post/SavePostTest.java b/src/test/java/com/ftm/server/post/SavePostTest.java new file mode 100644 index 0000000..e0b0df8 --- /dev/null +++ b/src/test/java/com/ftm/server/post/SavePostTest.java @@ -0,0 +1,434 @@ +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.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostProductRequest; +import com.ftm.server.adapter.in.web.post.dto.request.SavePostRequest; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3PostImageUploadPort; +import com.ftm.server.application.port.out.s3.S3PostProductImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import com.ftm.server.domain.enums.GroomingCategory; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +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.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 SavePostTest extends BaseTest { + + @MockitoSpyBean private S3PostImageUploadPort s3PostImageUploadPort; + @MockitoSpyBean private S3PostProductImageUploadPort s3PostProductImageUploadPort; + @MockitoSpyBean private S3ImageDeletePort s3ImageDeletePort; + @MockitoSpyBean private AfterRollbackExecutorPort afterRollbackExecutorPort; + + private final List requestPartSavePost = + 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 requestPartFieldSavePost = + List.of( + fieldWithPath("title").type(STRING).description("게시글 제목"), + fieldWithPath("groomingCategory").type(STRING).description("게시글 그루밍 분야"), + fieldWithPath("hashtags[]").type(ARRAY).optional().description("게시글 해시태그 목록"), + fieldWithPath("content").type(STRING).description("게시글 내용"), + fieldWithPath("products[]") + .type(ARRAY) + .optional() + .description("게시글에 포함된 상품 목록"), + fieldWithPath("products[].imageIndex") + .type(NUMBER) + .description( + "상품 이미지와 매핑될 인덱스 (**1**부터 시작, 이미지를 등록하지 않은 상품이라면 **-1** 저장) ** 순서 중요 **"), + fieldWithPath("products[].name").type(STRING).description("상품 이름"), + fieldWithPath("products[].brand").type(STRING).optional().description("상품 브랜드"), + fieldWithPath("products[].hashtags[]") + .type(ARRAY) + .optional() + .description("상품 해시태그 목록")); + + private final List responseFieldSavePost = + List.of( + fieldWithPath("status").type(NUMBER).description("응답 상태"), + fieldWithPath("code").type(STRING).description("상태 코드"), + fieldWithPath("message").type(STRING).description("메시지"), + fieldWithPath("data").type(OBJECT).optional().description("응답 데이터")); + + private ResultActions getResultActions( + MockHttpSession session, + MockMultipartFile data, + List postImageFiles, + List productImageFiles) + throws Exception { + MockMultipartHttpServletRequestBuilder builder = + (MockMultipartHttpServletRequestBuilder) + RestDocumentationRequestBuilders.multipart("/api/posts") + .file(data) // JSON part ("data") + .contentType(MediaType.MULTIPART_FORM_DATA) + .session(session) + .with( + request -> { + request.setMethod("POST"); + 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 ResultActions getResultActions(MockMultipartFile data) throws Exception { + return mockMvc.perform( + RestDocumentationRequestBuilders.multipart("/api/posts") + .file(data) // JSON part ("data") + .contentType(MediaType.MULTIPART_FORM_DATA) + .with( + request -> { + request.setMethod("POST"); + return request; + })); + } + + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "savePost/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + requestParts(requestPartSavePost), + requestPartFields("data", requestPartFieldSavePost), + responseFields(responseFieldSavePost), + resource( + ResourceSnippetParameters.builder() + .tag("유저픽 게시글") + .summary("유저픽 게시글 저장 api") + .description("유저픽 게시글 저장 api 입니다.") + .responseFields(responseFieldSavePost) + .build())); + } + + @Test + @Transactional + void 게시글_저장_성공() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", + GroomingCategory.BEAUTY, + List.of(), + "", + List.of( + new SavePostProductRequest(1, "독도 토너", "라운드랩", List.of()), + new SavePostProductRequest(2, "시카 크림", "더하르나이", List.of()), + new SavePostProductRequest(-1, "수분진정 앰플", "바이오던스", List.of()))); + 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", + IMAGE_JPEG_VALUE, + "test_01".getBytes()), + new MockMultipartFile( + "postImageFiles", + "test_02.jpg", + IMAGE_JPEG_VALUE, + "test_02".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", "posts/test_02.jpg")) + .when(s3PostImageUploadPort) + .uploadImages(new ArrayList<>(postImageFiles)); + doReturn(List.of("products/test_01.jpg", "products/test_02.jpg")) + .when(s3PostProductImageUploadPort) + .uploadImages(new ArrayList<>(productImageFiles)); + + doNothing().when(s3ImageDeletePort).deleteImages(any()); + doNothing().when(afterRollbackExecutorPort).doAfterRollback(any()); + + // when + ResultActions resultActions = + getResultActions(session, data, postImageFiles, productImageFiles); + + // then + resultActions.andExpect(status().isCreated()).andDo(print()); + + // document + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 게시글_저장_실패1() throws Exception { + // given + SavePostRequest postRequest = + new SavePostRequest( + "", + GroomingCategory.BEAUTY, + List.of(), + "", + List.of( + new SavePostProductRequest(-1, "", "라운드랩", List.of()), + new SavePostProductRequest(-1, "시카 크림", "더하르나이", List.of()), + new SavePostProductRequest(-1, "수분진정 앰플", "바이오던스", List.of()))); + MockMultipartFile data = + new MockMultipartFile( + "data", + "data.json", + APPLICATION_JSON_VALUE, + mapper.writeValueAsString(postRequest).getBytes(StandardCharsets.UTF_8)); + + // when + ResultActions resultActions = getResultActions(data); + + // 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 = createUserAndLogin(); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", + GroomingCategory.BEAUTY, + List.of(), + "", + List.of( + new SavePostProductRequest(1, "독도 토너", "라운드랩", List.of()), + new SavePostProductRequest(1, "시카 크림", "더하르나이", List.of()))); + 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, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.INVALID_POST_PRODUCT_IMAGE_MAPPING + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(3)); + } + + @Test + @Transactional + void 게시글_저장_실패3() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + SavePostRequest postRequest = + new SavePostRequest( + "", + GroomingCategory.BEAUTY, + List.of(), + "", + List.of( + new SavePostProductRequest(-1, "", "라운드랩", List.of()), + new SavePostProductRequest(-1, "시카 크림", "더하르나이", List.of()), + new SavePostProductRequest(-1, "수분진정 앰플", "바이오던스", List.of()))); + 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(); + + // when + ResultActions resultActions = + getResultActions(session, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is( + ErrorResponseCode.INVALID_REQUEST_ARGUMENT + .getHttpStatus() + .value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(4)); + } + + @Test + @Transactional + void 게시글_저장_실패4() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", GroomingCategory.BEAUTY, List.of(), "", List.of()); + 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, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.INVALID_IMAGE_FORMAT.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(5)); + } + + @Test + @Transactional + void 게시글_저장_실패5() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + SavePostRequest postRequest = + new SavePostRequest( + "독도 토너 추천", GroomingCategory.BEAUTY, List.of(), "", List.of()); + 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, data, postImageFiles, productImageFiles); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE.getHttpStatus().value())) + .andDo(print()); + + // document + resultActions.andDo(getDocument(6)); + } +} From 3759cedf7aa64a2d4b28b0db43d7f86cae7c09cd Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Thu, 17 Apr 2025 17:35:04 +0900 Subject: [PATCH 3/3] =?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=A0=80=EC=9E=A5=20=EB=AC=B8?= =?UTF-8?q?=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/index.adoc | 6 ++++- src/docs/asciidoc/post-api.adoc | 43 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/docs/asciidoc/post-api.adoc diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index b47a01a..7059548 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -129,4 +129,8 @@ include::auth-api.adoc[] = **그루밍 테스트** -include::grooming-test-api.adoc[] \ No newline at end of file +include::grooming-test-api.adoc[] + += **유저픽 게시글** + +include::post-api.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/post-api.adoc b/src/docs/asciidoc/post-api.adoc new file mode 100644 index 0000000..c5efb7f --- /dev/null +++ b/src/docs/asciidoc/post-api.adoc @@ -0,0 +1,43 @@ +=== **1. 게시글 저장** + +유저픽 게시글 저장, 등록 api 입니다. + +게시글, 게시글 이미지, 상품 , 상품 이미지를 함께 저장합니다. + +상품 요청 데이터의 `imageIndex` 는 해당 상품의 이미지가 `productImageFiles` 리스트 중 몇 번째에 위치하는지를 나타냅니다. + +`imageIndex` 는 **1부터 시작하는 인덱스**이며, 이미지가 없는 경우 `-1` 을 입력해야 합니다. + +`-1`, `1 ~ 이미지 총 개수` 를 제외한 인덱스로 요청 시 검증 로직에서 필터링되면서 예외발생 합니다. + +클라이언트는 `productImageFiles` 의 순서와 각 상품의 `imageIndex` 값이 정확히 매핑되도록 주의해야 합니다. + +==== Request +include::{snippetsDir}/savePost/1/curl-request.adoc[] + +==== Request Parts +include::{snippetsDir}/savePost/1/request-parts.adoc[] + +==== Request Parts : **data** - Detail Fields +include::{snippetsDir}/savePost/1/request-part-data-fields.adoc[] + +==== 성공 Response +include::{snippetsDir}/savePost/1/http-response.adoc[] + +==== 실패 Response +실패 1. 인증되지 않은 유저일 경우 + +include::{snippetsDir}/savePost/2/http-response.adoc[] + +실패 2. 이미지와 상품 정보가 매핑되지 않을 경우 + +- 상품 정보 imageIndex 중복 + +- 이미지 개수와 상품 개수 불일치 (이미지를 등록하지 않은 상품을 제외한 개수로 비교) + + +include::{snippetsDir}/savePost/3/http-response.adoc[] + +실패 3. 유효하지 않은 요청 데이터일 경우 + +include::{snippetsDir}/savePost/4/http-response.adoc[] + +실패 4. 요청한 파일 목록 검증에 실패할 경우 (이미지 파일 X, 사이즈) + +include::{snippetsDir}/savePost/5/http-response.adoc[] + +실패 5. 이미지 파일 업로드에 실패할 경우 + +include::{snippetsDir}/savePost/6/http-response.adoc[]