diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java index 6d312d36..389e49c7 100644 --- a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java @@ -1,6 +1,9 @@ package kr.spot.review.application.command; +import java.util.List; import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; import kr.spot.ports.dto.UploadResult; @@ -22,6 +25,7 @@ public class ManageReviewService { private static final String REVIEW_IMAGE_DIR = "reviews"; + private static final int MAX_REVIEW_IMAGE_COUNT = 3; private final IdGenerator idGenerator; private final GetWriterInfoPort getWriterInfoPort; @@ -30,13 +34,13 @@ public class ManageReviewService { private final StudyAccessValidator studyAccessValidator; public long createReview(long studyId, long memberId, CreateReviewRequest request, - MultipartFile imageFile) { + List imageFiles) { studyAccessValidator.validateStudyMember(studyId, memberId); WriterInfo writerInfo = getWriterInfo(memberId); - String imageUrl = uploadImage(imageFile); + List imageUrls = uploadImages(imageFiles); Content content = Content.of(request.activity(), request.learned(), request.encouragement(), - imageUrl); + imageUrls); long reviewId = idGenerator.nextId(); Review review = Review.of(reviewId, studyId, writerInfo, content, request.isPrivate()); @@ -58,10 +62,25 @@ private WriterInfo getWriterInfo(long memberId) { return WriterInfo.of(response.writerId(), response.nickname(), response.profileImageUrl()); } - private String uploadImage(MultipartFile imageFile) { - if (imageFile == null || imageFile.isEmpty()) { - return null; + private List uploadImages(List imageFiles) { + if (imageFiles == null || imageFiles.isEmpty()) { + return List.of(); + } + + List validImageFiles = imageFiles.stream() + .filter(file -> file != null && !file.isEmpty()) + .toList(); + + if (validImageFiles.size() > MAX_REVIEW_IMAGE_COUNT) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); } + + return validImageFiles.stream() + .map(this::uploadImage) + .toList(); + } + + private String uploadImage(MultipartFile imageFile) { UploadResult result = fileStoragePort.upload(imageFile, REVIEW_IMAGE_DIR); return result.url(); } diff --git a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java index 487ef9ea..628ff55f 100644 --- a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java +++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java @@ -96,14 +96,14 @@ private ContentResponse createContentResponse(Review review, Content content, PRIVATE_CONTENT_MESSAGE, PRIVATE_CONTENT_MESSAGE, PRIVATE_CONTENT_MESSAGE, - null + List.of() ); } return ContentResponse.from( content.getActivity(), content.getLearned(), content.getEncouragement(), - content.getImageUrl() + content.getImageUrls() ); } } diff --git a/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java index 8219e968..162538c8 100644 --- a/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java +++ b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java @@ -1,6 +1,8 @@ package kr.spot.review.domain.vo; import jakarta.persistence.Embeddable; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -24,9 +26,40 @@ public class Content { private String encouragement; private String imageUrl; + private String imageUrl2; + private String imageUrl3; public static Content of(String activity, String learned, - String encouragement, String imageUrl) { - return new Content(activity, learned, encouragement, imageUrl); + String encouragement, List imageUrls) { + List safeImageUrls = imageUrls == null ? List.of() : imageUrls; + return new Content( + activity, + learned, + encouragement, + getImageUrlByIndex(safeImageUrls, 0), + getImageUrlByIndex(safeImageUrls, 1), + getImageUrlByIndex(safeImageUrls, 2) + ); + } + + public List getImageUrls() { + List imageUrls = new ArrayList<>(); + if (imageUrl != null) { + imageUrls.add(imageUrl); + } + if (imageUrl2 != null) { + imageUrls.add(imageUrl2); + } + if (imageUrl3 != null) { + imageUrls.add(imageUrl3); + } + return List.copyOf(imageUrls); + } + + private static String getImageUrlByIndex(List imageUrls, int index) { + if (index >= imageUrls.size()) { + return null; + } + return imageUrls.get(index); } } diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java index 47e8cdd9..4f99ef04 100644 --- a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java @@ -3,6 +3,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.ArrayList; +import java.util.List; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; import kr.spot.code.status.SuccessStatus; @@ -37,9 +39,11 @@ public ResponseEntity> createReview( @PathVariable Long studyId, @CurrentMember @Parameter(hidden = true) Long memberId, @RequestPart CreateReviewRequest request, - @RequestPart(required = false) MultipartFile imageFile + @RequestPart(required = false) MultipartFile imageFile, + @RequestPart(required = false) List imageFiles ) { - long reviewId = manageReviewService.createReview(studyId, memberId, request, imageFile); + long reviewId = manageReviewService.createReview(studyId, memberId, request, + mergeImageFiles(imageFile, imageFiles)); return ResponseEntity.ok( ApiResponse.onSuccess(SuccessStatus._CREATED, CreateReviewResponse.from(reviewId))); } @@ -78,4 +82,16 @@ public ResponseEntity> removeReaction( manageReviewReactionService.removeReaction(studyId, reviewId, memberId, reaction); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + + private List mergeImageFiles(MultipartFile imageFile, + List imageFiles) { + List mergedFiles = new ArrayList<>(); + if (imageFiles != null) { + mergedFiles.addAll(imageFiles); + } + if (imageFile != null) { + mergedFiles.add(imageFile); + } + return mergedFiles; + } } diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java index 786ec666..c069d183 100644 --- a/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java @@ -50,12 +50,12 @@ public record ContentResponse( String activity, String learned, String encouragement, - String imageUrl + List imageUrls ) { public static ContentResponse from(String activity, String learned, String encouragement, - String imageUrl) { - return new ContentResponse(activity, learned, encouragement, imageUrl); + List imageUrls) { + return new ContentResponse(activity, learned, encouragement, imageUrls); } } diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java index 8fb318fa..0e4887c5 100644 --- a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java +++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java @@ -19,9 +19,11 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -154,12 +156,82 @@ void should_create_review_with_image() { .thenAnswer(invocation -> invocation.getArgument(0)); // when - manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, imageFile); + manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, List.of(imageFile)); // then verify(fileStoragePort).upload(imageFile, "reviews"); verify(reviewRepository).save(reviewCaptor.capture()); - assertThat(reviewCaptor.getValue().getContent().getImageUrl()).isEqualTo(uploadedImageUrl); + assertThat(reviewCaptor.getValue().getContent().getImageUrls()).containsExactly(uploadedImageUrl); + } + + @Test + @DisplayName("이미지는 최대 3개까지만 등록할 수 있다") + void should_throw_exception_when_images_exceed_limit() { + // given + CreateReviewRequest request = createReviewRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME, + WRITER_PROFILE_IMAGE_URL); + MultipartFile imageFile1 = new MockMultipartFile("image1", "1.jpg", "image/jpeg", + "1".getBytes()); + MultipartFile imageFile2 = new MockMultipartFile("image2", "2.jpg", "image/jpeg", + "2".getBytes()); + MultipartFile imageFile3 = new MockMultipartFile("image3", "3.jpg", "image/jpeg", + "3".getBytes()); + MultipartFile imageFile4 = new MockMultipartFile("image4", "4.jpg", "image/jpeg", + "4".getBytes()); + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse); + + // when & then + assertThatThrownBy(() -> manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, + List.of(imageFile1, imageFile2, imageFile3, imageFile4))) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._BAD_REQUEST); + + verify(fileStoragePort, never()).upload(any(MultipartFile.class), anyString()); + verify(reviewRepository, never()).save(any()); + } + + @Test + @DisplayName("이미지 3개를 모두 등록할 수 있다") + void should_create_review_with_three_images() { + // given + Long generatedId = 1L; + CreateReviewRequest request = createReviewRequest(); + WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME, + WRITER_PROFILE_IMAGE_URL); + MultipartFile imageFile1 = new MockMultipartFile("image1", "1.jpg", "image/jpeg", + "1".getBytes()); + MultipartFile imageFile2 = new MockMultipartFile("image2", "2.jpg", "image/jpeg", + "2".getBytes()); + MultipartFile imageFile3 = new MockMultipartFile("image3", "3.jpg", "image/jpeg", + "3".getBytes()); + String uploadedImageUrl1 = "https://s3.example.com/reviews/1.jpg"; + String uploadedImageUrl2 = "https://s3.example.com/reviews/2.jpg"; + String uploadedImageUrl3 = "https://s3.example.com/reviews/3.jpg"; + + doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse); + when(fileStoragePort.upload(any(MultipartFile.class), anyString())) + .thenReturn( + new UploadResult(uploadedImageUrl1, "1.jpg"), + new UploadResult(uploadedImageUrl2, "2.jpg"), + new UploadResult(uploadedImageUrl3, "3.jpg") + ); + when(reviewRepository.save(any(Review.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, + List.of(imageFile1, imageFile2, imageFile3)); + + // then + verify(fileStoragePort, times(3)).upload(any(MultipartFile.class), anyString()); + verify(reviewRepository).save(reviewCaptor.capture()); + assertThat(reviewCaptor.getValue().getContent().getImageUrls()) + .containsExactly(uploadedImageUrl1, uploadedImageUrl2, uploadedImageUrl3); } @Test diff --git a/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java index 2e44f7f7..cc640fe6 100644 --- a/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java +++ b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java @@ -231,7 +231,7 @@ void should_mask_content_for_non_study_member() { assertThat(reviewResponse.content().activity()).isEqualTo(PRIVATE_CONTENT_MESSAGE); assertThat(reviewResponse.content().learned()).isEqualTo(PRIVATE_CONTENT_MESSAGE); assertThat(reviewResponse.content().encouragement()).isEqualTo(PRIVATE_CONTENT_MESSAGE); - assertThat(reviewResponse.content().imageUrl()).isNull(); + assertThat(reviewResponse.content().imageUrls()).isEmpty(); } @Test diff --git a/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java index 2025d176..fedc0ffa 100644 --- a/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java +++ b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java @@ -1,5 +1,6 @@ package kr.spot.review.common; +import java.util.List; import kr.spot.review.domain.Review; import kr.spot.review.domain.associations.ReviewReaction; import kr.spot.review.domain.enums.Reaction; @@ -21,6 +22,8 @@ public class ReviewFixture { public static final String LEARNED = "다이나믹 프로그래밍의 개념을 이해했습니다."; public static final String ENCOURAGEMENT = "오늘도 수고했어!"; public static final String IMAGE_URL = "https://example.com/review-image.jpg"; + public static final String IMAGE_URL_2 = "https://example.com/review-image-2.jpg"; + public static final String IMAGE_URL_3 = "https://example.com/review-image-3.jpg"; public static WriterInfo writerInfo() { return WriterInfo.of(MEMBER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL); @@ -31,11 +34,17 @@ public static WriterInfo writerInfo(Long memberId) { } public static Content content() { - return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, IMAGE_URL); + return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, List.of(IMAGE_URL)); } public static Content content(String imageUrl) { - return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, imageUrl); + return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, imageUrl == null + ? List.of() + : List.of(imageUrl)); + } + + public static Content content(List imageUrls) { + return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, imageUrls); } public static Review review() {