diff --git a/server/src/main/java/server/haengdong/application/EventImageFacadeService.java b/server/src/main/java/server/haengdong/application/EventImageFacadeService.java new file mode 100644 index 000000000..8c5bbe946 --- /dev/null +++ b/server/src/main/java/server/haengdong/application/EventImageFacadeService.java @@ -0,0 +1,25 @@ +package server.haengdong.application; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@RequiredArgsConstructor +@Service +public class EventImageFacadeService { + + private final EventService eventService; + private final ImageService imageService; + + public void uploadImages(String token, List images) { + eventService.validateImageCount(token, images.size()); + List imageNames = imageService.uploadImages(images); + eventService.saveImages(token, imageNames); + } + + public void deleteImage(String token, Long imageId) { + String imageName = eventService.deleteImage(token, imageId); + imageService.deleteImage(imageName); + } +} diff --git a/server/src/main/java/server/haengdong/application/EventService.java b/server/src/main/java/server/haengdong/application/EventService.java index 2eb80f265..cb0972f8c 100644 --- a/server/src/main/java/server/haengdong/application/EventService.java +++ b/server/src/main/java/server/haengdong/application/EventService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import server.haengdong.application.request.EventAppRequest; import server.haengdong.application.request.EventLoginAppRequest; @@ -31,6 +32,8 @@ @Service public class EventService { + private static final int MAX_IMAGE_COUNT = 10; + private final EventRepository eventRepository; private final EventTokenProvider eventTokenProvider; private final BillRepository billRepository; @@ -137,4 +140,14 @@ public String deleteImage(String token, Long imageId) { eventImageRepository.delete(eventImage); return eventImage.getName(); } + + public void validateImageCount(String token, int uploadImageCount) { + Event event = getEvent(token); + Long imageCount = eventImageRepository.countByEvent(event); + Long totalImageCount = imageCount + uploadImageCount; + + if (totalImageCount > MAX_IMAGE_COUNT) { + throw new HaengdongException(HaengdongErrorCode.IMAGE_COUNT_INVALID, totalImageCount); + } + } } diff --git a/server/src/main/java/server/haengdong/application/ImageService.java b/server/src/main/java/server/haengdong/application/ImageService.java index b6d64b033..716ad8227 100644 --- a/server/src/main/java/server/haengdong/application/ImageService.java +++ b/server/src/main/java/server/haengdong/application/ImageService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import lombok.RequiredArgsConstructor; @@ -39,12 +40,12 @@ public List uploadImages(List images) { .map(image -> CompletableFuture.supplyAsync(() -> uploadImage(image), executorService)) .toList(); - CompletableFuture> result = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenApply(v -> futures.stream() - .map(this::getFuture) - .toList()); - - return result.join(); + try { + CompletableFuture> result = collectUploadResults(futures); + return result.join(); + } catch (CompletionException e) { + throw new HaengdongException(HaengdongErrorCode.IMAGE_UPLOAD_FAIL, e); + } } private String uploadImage(MultipartFile image) { @@ -71,7 +72,14 @@ private String uploadImageToStorage(InputStream inputStream, MultipartFile image return imageName; } - private String getFuture(CompletableFuture future) { + private CompletableFuture> collectUploadResults(List> futures) { + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> futures.stream() + .map(this::resolveFuture) + .toList()); + } + + private String resolveFuture(CompletableFuture future) { try { return future.get(); } catch (InterruptedException | ExecutionException e) { diff --git a/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java b/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java index 00f84b8af..c8c9e82e4 100644 --- a/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java +++ b/server/src/main/java/server/haengdong/domain/event/EventImageRepository.java @@ -8,4 +8,6 @@ public interface EventImageRepository extends JpaRepository { List findAllByEvent(Event event); + + Long countByEvent(Event event); } diff --git a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java index fc79123b9..39b1014b0 100644 --- a/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java +++ b/server/src/main/java/server/haengdong/exception/HaengdongErrorCode.java @@ -31,6 +31,7 @@ public enum HaengdongErrorCode { DIFFERENT_STEP_MEMBERS("참여자 목록이 일치하지 않습니다."), IMAGE_NOT_FOUND("존재하지 않는 이미지 입니다."), + IMAGE_COUNT_INVALID("이미지 수량은 %d개 까지 업로드할 수 있습니다."), /* Authentication */ diff --git a/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java index 8fd913dae..0d47c2012 100644 --- a/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java +++ b/server/src/main/java/server/haengdong/presentation/admin/AdminEventController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import server.haengdong.application.EventImageFacadeService; import server.haengdong.application.EventService; import server.haengdong.application.ImageService; import server.haengdong.application.response.ImageNameAppResponse; @@ -24,7 +25,7 @@ public class AdminEventController { private final EventService eventService; - private final ImageService imageUploadService; + private final EventImageFacadeService eventImageFacadeService; @PostMapping("/api/admin/events/{eventId}/auth") public ResponseEntity authenticate() { @@ -46,8 +47,7 @@ public ResponseEntity uploadImages( @PathVariable("eventId") String token, @RequestPart("images") List images ) { - List imageNames = imageUploadService.uploadImages(images); - eventService.saveImages(token, imageNames); + eventImageFacadeService.uploadImages(token, images); return ResponseEntity.ok().build(); } @@ -57,8 +57,7 @@ public ResponseEntity deleteImage( @PathVariable("eventId") String token, @PathVariable("imageId") Long imageId ) { - String imageName = eventService.deleteImage(token, imageId); - imageUploadService.deleteImage(imageName); + eventImageFacadeService.deleteImage(token, imageId); return ResponseEntity.ok().build(); } diff --git a/server/src/test/java/server/haengdong/application/EventServiceTest.java b/server/src/test/java/server/haengdong/application/EventServiceTest.java index bcfc0a3e4..a849209e1 100644 --- a/server/src/test/java/server/haengdong/application/EventServiceTest.java +++ b/server/src/test/java/server/haengdong/application/EventServiceTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.tuple; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.BDDMockito.given; @@ -28,6 +29,7 @@ import server.haengdong.domain.event.Event; import server.haengdong.domain.event.EventRepository; import server.haengdong.domain.event.EventTokenProvider; +import server.haengdong.exception.HaengdongException; import server.haengdong.support.fixture.Fixture; class EventServiceTest extends ServiceTestSupport { @@ -228,4 +230,17 @@ void deleteImage() { assertThat(eventImageRepository.findById(eventImage.getId())) .isEmpty(); } + + @DisplayName("행사 1개당 이미지는 10개까지 업로드 가능하다.") + @Test + void validateImageCount() { + Event event = Fixture.EVENT1; + eventRepository.save(event); + List imageNames = List.of("image1.jpg", "image2.jpg"); + String token = event.getToken(); + eventService.saveImages(token, imageNames); + + assertThatThrownBy(() -> eventService.validateImageCount(token, 9)) + .isInstanceOf(HaengdongException.class); + } } diff --git a/server/src/test/java/server/haengdong/application/ImageServiceTest.java b/server/src/test/java/server/haengdong/application/ImageServiceTest.java new file mode 100644 index 000000000..16dc695a1 --- /dev/null +++ b/server/src/test/java/server/haengdong/application/ImageServiceTest.java @@ -0,0 +1,48 @@ +package server.haengdong.application; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.given; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import server.haengdong.exception.HaengdongException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + + +class ImageServiceTest extends ServiceTestSupport { + + @Autowired + private ImageService imageService; + + @MockBean + private S3Client s3Client; + + @DisplayName("이미지 업로드 도중 실패하면 예외가 커스텀 예외가 발생한다.") + @Test + void uploadImages() { + MultipartFile multipartFile1 = new MockMultipartFile("file1", "test1.txt", "text/plain", "hello1".getBytes()); + MultipartFile multipartFile2 = new MockMultipartFile("file2", "test2.txt", "text/plain", "hello2".getBytes()); + MultipartFile multipartFile3 = new MockMultipartFile("file3", "test3.txt", "text/plain", "hello3".getBytes()); + MultipartFile multipartFile4 = new MockMultipartFile("file4", "test4.txt", "text/plain", "hello4".getBytes()); + MultipartFile multipartFile5 = new MockMultipartFile("file5", "test5.txt", "text/plain", "hello5".getBytes()); + + doThrow(new RuntimeException("S3 upload failed")) + .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + + assertThatThrownBy(() -> imageService.uploadImages(List.of(multipartFile1, multipartFile2, multipartFile3, multipartFile4, multipartFile5))) + .isInstanceOf(HaengdongException.class); + } +} diff --git a/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java index 658c57512..b5bb5cda8 100644 --- a/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java +++ b/server/src/test/java/server/haengdong/docs/AdminEventControllerDocsTest.java @@ -26,19 +26,19 @@ import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.payload.JsonFieldType; +import server.haengdong.application.EventImageFacadeService; import server.haengdong.application.EventService; -import server.haengdong.application.ImageService; import server.haengdong.presentation.admin.AdminEventController; import server.haengdong.presentation.request.EventUpdateRequest; class AdminEventControllerDocsTest extends RestDocsSupport { private final EventService eventService = mock(EventService.class); - private final ImageService imageUploadService = mock(ImageService.class); + private final EventImageFacadeService eventImageFacadeService = mock(EventImageFacadeService.class); @Override protected Object initController() { - return new AdminEventController(eventService, imageUploadService); + return new AdminEventController(eventService, eventImageFacadeService); } @DisplayName("행사 어드민 권한을 확인한다.") diff --git a/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java index 738823c40..3d7763ee0 100644 --- a/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java +++ b/server/src/test/java/server/haengdong/presentation/ControllerTestSupport.java @@ -10,6 +10,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import server.haengdong.application.AuthService; import server.haengdong.application.BillService; +import server.haengdong.application.EventImageFacadeService; import server.haengdong.application.EventService; import server.haengdong.application.ImageService; import server.haengdong.application.MemberService; @@ -49,5 +50,5 @@ public abstract class ControllerTestSupport { protected BillService billService; @MockBean - protected ImageService imageUploadService; + protected EventImageFacadeService eventImageFacadeService; }