From 740dba21f2a4b9f156773765224ebeb1b1eb5636 Mon Sep 17 00:00:00 2001 From: sso0om Date: Mon, 13 Oct 2025 00:21:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mentoring/error/ImageErrorCode.java | 18 +++ .../mentoring/service/S3ImageUploader.java | 63 ++++++++ .../service/S3ImageUploaderTest.java | 135 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 back/src/main/java/com/back/domain/mentoring/mentoring/error/ImageErrorCode.java create mode 100644 back/src/main/java/com/back/domain/mentoring/mentoring/service/S3ImageUploader.java create mode 100644 back/src/test/java/com/back/domain/mentoring/mentoring/service/S3ImageUploaderTest.java diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/error/ImageErrorCode.java b/back/src/main/java/com/back/domain/mentoring/mentoring/error/ImageErrorCode.java new file mode 100644 index 00000000..095201bc --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/error/ImageErrorCode.java @@ -0,0 +1,18 @@ +package com.back.domain.mentoring.mentoring.error; + +import com.back.global.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ImageErrorCode implements ErrorCode { + // 400 + FILE_SIZE_EXCEEDED("400-1", "이미지 파일 크기는 10MB를 초과할 수 없습니다."), + INVALID_FILE_TYPE("400-2", "이미지 파일만 업로드 가능합니다."), + UNSUPPORTED_IMAGE_FORMAT("400-3", "JPG, PNG 형식만 업로드 가능합니다."), + IMAGE_UPLOAD_FAILED("400-4", "이미지 업로드에 실패했습니다."); + + private final String code; + private final String message; +} diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/S3ImageUploader.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/S3ImageUploader.java new file mode 100644 index 00000000..7e6551f9 --- /dev/null +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/S3ImageUploader.java @@ -0,0 +1,63 @@ +package com.back.domain.mentoring.mentoring.service; + +import com.back.domain.mentoring.mentoring.error.ImageErrorCode; +import com.back.global.exception.ServiceException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class S3ImageUploader { + + private final S3Client s3Client; + + @Value("${aws.s3.bucket}") + private String bucket; + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final Set ALLOWED_TYPES = Set.of("image/jpeg", "image/jpg", "image/png", "image/webp"); + private static final String IMAGE_BASE_PATH = "images/"; + + public String upload(MultipartFile file, String path) throws IOException { + validateImageFile(file); + + String fullPath = IMAGE_BASE_PATH + path; + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fullPath) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject( + putObjectRequest, + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + + return s3Client.utilities() + .getUrl(builder -> builder.bucket(bucket).key(fullPath)) + .toString(); + } + + private void validateImageFile(MultipartFile image) { + if (image.getSize() > MAX_FILE_SIZE) { + throw new ServiceException(ImageErrorCode.FILE_SIZE_EXCEEDED); + } + + String contentType = image.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new ServiceException(ImageErrorCode.INVALID_FILE_TYPE); + } + if (!ALLOWED_TYPES.contains(contentType)) { + throw new ServiceException(ImageErrorCode.INVALID_FILE_TYPE); + } + } +} diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/service/S3ImageUploaderTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/service/S3ImageUploaderTest.java new file mode 100644 index 00000000..e6d73424 --- /dev/null +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/service/S3ImageUploaderTest.java @@ -0,0 +1,135 @@ +package com.back.domain.mentoring.mentoring.service; + +import com.back.domain.mentoring.mentoring.error.ImageErrorCode; +import com.back.global.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Utilities; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import java.io.IOException; +import java.net.URI; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class S3ImageUploaderTest { + + @Mock + private S3Client s3Client; + + @Mock + private S3Utilities s3Utilities; + + @InjectMocks + private S3ImageUploader s3ImageUploader; + + private static final String BUCKET_NAME = "test-bucket"; + private static final String IMAGE_BASE_PATH = "images/"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(s3ImageUploader, "bucket", BUCKET_NAME); + } + + @Test + @DisplayName("이미지 업로드 성공") + void upload_success() throws IOException { + // given + MockMultipartFile image = new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test-image-content".getBytes() + ); + String path = "mentoring/123"; + String expectedKey = IMAGE_BASE_PATH + path; + String expectedUrl = String.format("https://%s.s3.amazonaws.com/%s", BUCKET_NAME, expectedKey); + + // PutObjectRequest 객체를 캡처하기 위한 ArgumentCaptor 생성 + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + + // putObject + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().build()); + + // getUrl + when(s3Client.utilities()).thenReturn(s3Utilities); + when(s3Utilities.getUrl(ArgumentMatchers.>any())) + .thenReturn(URI.create(expectedUrl).toURL()); + + // when + String result = s3ImageUploader.upload(image, path); + + // then + assertThat(result).isEqualTo(expectedUrl); + + verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + + // 캡처된 객체(실제 서비스에서 생성된 PutObjectRequest)의 내부 필드 검증 + PutObjectRequest capturedRequest = requestCaptor.getValue(); + + // 버킷 이름, S3 Key Content-Type 검증 + assertThat(capturedRequest.bucket()).isEqualTo(BUCKET_NAME); + assertThat(capturedRequest.key()).isEqualTo(expectedKey); + assertThat(capturedRequest.contentType()).isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("파일 크기 10MB 초과 시 예외") + void upload_fail_fileSizeExceeded() { + // given + long exceedingSize = 10 * 1024 * 1024 + 1; + MockMultipartFile largeFile = new MockMultipartFile( + "image", + "large.jpg", + "image/jpeg", + new byte[(int) exceedingSize] + ); + + // when & then + assertThatThrownBy(() -> s3ImageUploader.upload(largeFile, "mentoring/123")) + .isInstanceOf(ServiceException.class) + .hasFieldOrPropertyWithValue("resultCode", ImageErrorCode.FILE_SIZE_EXCEEDED.getCode()); + + // S3 putObject는 호출되지 않았는지 검증 + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + @DisplayName("허용되지 않은 파일 타입이면 예외") + void upload_fail_invalidFileType() { + // given + MockMultipartFile textFile = new MockMultipartFile( + "file", + "doc.txt", + "text/plain", + "test content".getBytes() + ); + + // when & then + assertThatThrownBy(() -> s3ImageUploader.upload(textFile, "mentoring/123")) + .isInstanceOf(ServiceException.class) + .hasFieldOrPropertyWithValue("resultCode", ImageErrorCode.INVALID_FILE_TYPE.getCode()); + + // S3 putObject는 호출되지 않았는지 검증 + verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } +} \ No newline at end of file From 21e038dd26273b4940fd231148faa470b26c307a Mon Sep 17 00:00:00 2001 From: sso0om Date: Mon, 13 Oct 2025 00:28:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Refactor:=20=EB=A9=98=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?s3=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20url=20db=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MentoringController.java | 9 ++- .../mentoring/mentoring/entity/Mentoring.java | 7 ++- .../mentoring/service/MentoringService.java | 21 ++++++- .../back/global/initData/SessionInitData.java | 3 +- .../controller/MentoringControllerTest.java | 17 ++++-- .../service/MentoringServiceTest.java | 55 +++++++++++++++++-- .../fixture/mentoring/MentoringFixture.java | 7 ++- 7 files changed, 97 insertions(+), 22 deletions(-) diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java index 3bd0964d..515b233c 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java @@ -14,8 +14,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -73,14 +75,15 @@ public RsData getMentoring( ); } - @PostMapping + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasRole('MENTOR')") @Operation(summary = "멘토링 생성", description = "멘토링을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") public RsData createMentoring( - @RequestBody @Valid MentoringRequest reqDto + @RequestPart("reqDto") @Valid MentoringRequest reqDto, + @RequestPart(value = "thumb", required = false) MultipartFile thumb ) { Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); - MentoringResponse resDto = mentoringService.createMentoring(reqDto, mentor); + MentoringResponse resDto = mentoringService.createMentoring(reqDto, thumb, mentor); return new RsData<>( "201", diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java index 7e90b6a4..fe166a76 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java @@ -34,11 +34,10 @@ public class Mentoring extends BaseEntity { private double rating = 0.0; @Builder - public Mentoring(Mentor mentor, String title, String bio, String thumb) { + public Mentoring(Mentor mentor, String title, String bio) { this.mentor = mentor; this.title = title; this.bio = bio; - this.thumb = thumb; } public void update(String title, String bio, List tags, String thumb) { @@ -59,6 +58,10 @@ public void updateTags(List tags) { } } + public void updateThumb(String thumb) { + this.thumb = thumb; + } + public void updateRating(double averageRating) { this.rating = averageRating; } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java index 545a00d0..3299038d 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java @@ -8,6 +8,7 @@ import com.back.domain.mentoring.mentoring.dto.response.MentoringResponse; import com.back.domain.mentoring.mentoring.entity.Mentoring; import com.back.domain.mentoring.mentoring.entity.Tag; +import com.back.domain.mentoring.mentoring.error.ImageErrorCode; import com.back.domain.mentoring.mentoring.error.MentoringErrorCode; import com.back.domain.mentoring.mentoring.repository.MentoringRepository; import com.back.domain.mentoring.mentoring.repository.TagRepository; @@ -18,7 +19,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -30,6 +33,7 @@ public class MentoringService { private final MentoringRepository mentoringRepository; private final MentoringStorage mentoringStorage; private final TagRepository tagRepository; + private final S3ImageUploader s3ImageUploader; @Transactional(readOnly = true) public Page getMentorings(String keyword, int page, int size) { @@ -58,20 +62,31 @@ public MentoringResponse getMentoring(Long mentoringId) { } @Transactional - public MentoringResponse createMentoring(MentoringRequest reqDto, Mentor mentor) { + public MentoringResponse createMentoring(MentoringRequest reqDto, MultipartFile thumb, Mentor mentor) { validateMentoringTitle(mentor.getId(), reqDto.title()); Mentoring mentoring = Mentoring.builder() .mentor(mentor) .title(reqDto.title()) .bio(reqDto.bio()) - .thumb(reqDto.thumb()) .build(); List tags = getOrCreateTags(reqDto.tags()); mentoring.updateTags(tags); - mentoringRepository.save(mentoring); + mentoringRepository.saveAndFlush(mentoring); + + if (thumb != null && !thumb.isEmpty()) { + String imageUrl = null; + try { + String path = "mentoring/" + mentoring.getId(); + imageUrl = s3ImageUploader.upload(thumb, path); + } catch (IOException e) { + throw new ServiceException(ImageErrorCode.IMAGE_UPLOAD_FAILED); + } + + mentoring.updateThumb(imageUrl); + } return new MentoringResponse( MentoringDetailDto.from(mentoring), diff --git a/back/src/main/java/com/back/global/initData/SessionInitData.java b/back/src/main/java/com/back/global/initData/SessionInitData.java index f51df3ed..f3b95c3a 100644 --- a/back/src/main/java/com/back/global/initData/SessionInitData.java +++ b/back/src/main/java/com/back/global/initData/SessionInitData.java @@ -16,7 +16,6 @@ import com.back.domain.mentoring.slot.service.MentorSlotService; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.LocalDateTime; @@ -45,7 +44,7 @@ public CommandLineRunner initData() { // 멘토링 생성 MentoringRequest mentoringRequest = new MentoringRequest("Test Mentoring", Arrays.asList("Java", "Spring"), "This is a test mentoring.", null); - MentoringResponse mentoringResponse = mentoringService.createMentoring(mentoringRequest, mentor); + MentoringResponse mentoringResponse = mentoringService.createMentoring(mentoringRequest, null, mentor); // 멘토 슬롯 생성 MentorSlotRequest mentorSlotRequest = new MentorSlotRequest(mentor.getId(), LocalDateTime.now().plusDays(1), LocalDateTime.now().plusDays(1).plusHours(1)); diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java index 47118687..c1d62b0c 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java @@ -20,11 +20,13 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -340,17 +342,22 @@ private ResultActions performCreateMentoring(String token) throws Exception { { "title": "Spring Boot 멘토링", "tags": ["Spring", "Java"], - "bio": "Spring Boot를 활용한 백엔드 개발 입문", - "thumb": "https://example.com/thumb.jpg" + "bio": "Spring Boot를 활용한 백엔드 개발 입문" } """; + MockMultipartFile jsonPart = new MockMultipartFile( + "reqDto", + null, + MediaType.APPLICATION_JSON_VALUE, + req.getBytes(StandardCharsets.UTF_8) + ); + return mvc .perform( - post(MENTORING_URL) + multipart(MENTORING_URL) + .file(jsonPart) .cookie(new Cookie(TOKEN, token)) - .contentType(MediaType.APPLICATION_JSON) - .content(req) ) .andDo(print()) .andExpect(handler().handlerType(MentoringController.class)) diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java index c416080f..78e048ad 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java @@ -27,7 +27,10 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import java.io.IOException; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -50,6 +53,9 @@ class MentoringServiceTest { @Mock private MentoringStorage mentoringStorage; + @Mock + private S3ImageUploader s3ImageUploader; + private Mentor mentor1, mentor2; private Mentoring mentoring1; private MentoringRequest request; @@ -199,17 +205,58 @@ void createMentoring() { .thenReturn(false); // when - MentoringResponse result = mentoringService.createMentoring(request, mentor1); + MentoringResponse result = mentoringService.createMentoring(request, null, mentor1); // then assertThat(result).isNotNull(); assertThat(result.mentoring().title()).isEqualTo(request.title()); assertThat(result.mentoring().bio()).isEqualTo(request.bio()); assertThat(result.mentoring().tags()).isEqualTo(request.tags()); - assertThat(result.mentoring().thumb()).isEqualTo(request.thumb()); verify(mentoringRepository).existsByMentorIdAndTitle(mentor1.getId(), request.title()); verify(tagRepository).findByNameIn(request.tags()); - verify(mentoringRepository).save(any(Mentoring.class)); + verify(mentoringRepository).saveAndFlush(any(Mentoring.class)); + } + + @Test + @DisplayName("생성 성공 - 썸네일 포함") + void createMentoringWithImage() throws IOException { + // given + List tags = TagFixture.createDefaultTags(); + MockMultipartFile image = new MockMultipartFile( + "thumb", + "test.jpg", + "image/jpeg", + "test-image-content".getBytes() + ); + String expectedImageUrl = "https://fivelogic-files-bucket.s3.amazonaws.com/images/mentoring/%d".formatted(mentoring1.getId()); + + when(tagRepository.findByNameIn(request.tags())) + .thenReturn(tags); + when(mentoringRepository.existsByMentorIdAndTitle(mentor1.getId(), request.title())) + .thenReturn(false); + when(mentoringRepository.saveAndFlush(any(Mentoring.class))) + .thenAnswer(invocation -> { + Mentoring mentoring = invocation.getArgument(0); + ReflectionTestUtils.setField(mentoring, "id", mentoring1.getId()); + return mentoring; + }); + when(s3ImageUploader.upload(eq(image), anyString())) + .thenReturn(expectedImageUrl); + + // when + MentoringResponse result = mentoringService.createMentoring(request, image, mentor1); + + // then + assertThat(result).isNotNull(); + assertThat(result.mentoring().title()).isEqualTo(request.title()); + assertThat(result.mentoring().bio()).isEqualTo(request.bio()); + assertThat(result.mentoring().tags()).isEqualTo(request.tags()); + assertThat(result.mentoring().thumb()).isEqualTo(expectedImageUrl); + + verify(mentoringRepository).existsByMentorIdAndTitle(mentor1.getId(), request.title()); + verify(tagRepository).findByNameIn(request.tags()); + verify(mentoringRepository).saveAndFlush(any(Mentoring.class)); + verify(s3ImageUploader).upload(eq(image), eq("mentoring/1")); } @Test @@ -220,7 +267,7 @@ void throwExceptionWhenAlreadyExists() { .thenReturn(true); // when & then - assertThatThrownBy(() -> mentoringService.createMentoring(request, mentor1)) + assertThatThrownBy(() -> mentoringService.createMentoring(request, null, mentor1)) .isInstanceOf(ServiceException.class) .hasFieldOrPropertyWithValue("resultCode", MentoringErrorCode.ALREADY_EXISTS_MENTORING.getCode()); verify(mentoringRepository).existsByMentorIdAndTitle(mentor1.getId(), request.title()); diff --git a/back/src/test/java/com/back/fixture/mentoring/MentoringFixture.java b/back/src/test/java/com/back/fixture/mentoring/MentoringFixture.java index c5ccb7dc..d2c3b6a2 100644 --- a/back/src/test/java/com/back/fixture/mentoring/MentoringFixture.java +++ b/back/src/test/java/com/back/fixture/mentoring/MentoringFixture.java @@ -19,9 +19,10 @@ public static Mentoring create(Mentor mentor) { .mentor(mentor) .title(DEFAULT_TITLE) .bio(DEFAULT_BIO) - .thumb(DEFAULT_THUMB) .build(); mentoring.updateTags(DEFAULT_TAGS); + mentoring.updateThumb(DEFAULT_THUMB); + return mentoring; } @@ -30,9 +31,9 @@ public static Mentoring create(Long id, Mentor mentor) { .mentor(mentor) .title(DEFAULT_TITLE) .bio(DEFAULT_BIO) - .thumb(DEFAULT_THUMB) .build(); mentoring.updateTags(DEFAULT_TAGS); + mentoring.updateThumb(DEFAULT_THUMB); ReflectionTestUtils.setField(mentoring, "id", id); return mentoring; @@ -43,9 +44,9 @@ public static Mentoring create(Long id, Mentor mentor, String title, String bio, .mentor(mentor) .title(DEFAULT_TITLE) .bio(DEFAULT_BIO) - .thumb(DEFAULT_THUMB) .build(); mentoring.updateTags(DEFAULT_TAGS); + mentoring.updateThumb(DEFAULT_THUMB); if (id != null) { ReflectionTestUtils.setField(mentoring, "id", id); From b5918cbb191d9c63e32a086f45237127bf23a2de Mon Sep 17 00:00:00 2001 From: sso0om Date: Mon, 13 Oct 2025 00:57:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Refactor:=20=EB=A9=98=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?s3=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20url=20db=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MentoringController.java | 15 +++++--- .../dto/request/MentoringRequest.java | 5 +-- .../mentoring/mentoring/entity/Mentoring.java | 3 +- .../mentoring/service/MentoringService.java | 34 ++++++++++++------- .../back/global/initData/SessionInitData.java | 2 +- .../controller/MentoringControllerTest.java | 33 +++++++++++------- .../service/MentoringServiceTest.java | 8 ++--- 7 files changed, 59 insertions(+), 41 deletions(-) diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java index 515b233c..cfde4b5d 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/controller/MentoringController.java @@ -10,6 +10,8 @@ import com.back.global.rq.Rq; import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -79,7 +81,8 @@ public RsData getMentoring( @PreAuthorize("hasRole('MENTOR')") @Operation(summary = "멘토링 생성", description = "멘토링을 생성합니다. 로그인한 멘토만 생성할 수 있습니다.") public RsData createMentoring( - @RequestPart("reqDto") @Valid MentoringRequest reqDto, + @Parameter(content = @Content(mediaType = "application/json")) + @RequestPart(value = "reqDto") @Valid MentoringRequest reqDto, @RequestPart(value = "thumb", required = false) MultipartFile thumb ) { Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); @@ -92,14 +95,17 @@ public RsData createMentoring( ); } - @PutMapping("/{mentoringId}") + @PutMapping(value = "/{mentoringId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('MENTOR')") @Operation(summary = "멘토링 수정", description = "멘토링을 수정합니다. 멘토링 작성자만 접근할 수 있습니다.") public RsData updateMentoring( @PathVariable Long mentoringId, - @RequestBody @Valid MentoringRequest reqDto + @Parameter(content = @Content(mediaType = "application/json")) + @RequestPart(value = "reqDto") @Valid MentoringRequest reqDto, + @RequestPart(value = "thumb", required = false) MultipartFile thumb ) { Mentor mentor = memberStorage.findMentorByMember(rq.getActor()); - MentoringResponse resDto = mentoringService.updateMentoring(mentoringId, reqDto, mentor); + MentoringResponse resDto = mentoringService.updateMentoring(mentoringId, reqDto, thumb, mentor); return new RsData<>( "200", @@ -109,6 +115,7 @@ public RsData updateMentoring( } @DeleteMapping("/{mentoringId}") + @PreAuthorize("hasRole('MENTOR')") @Operation(summary = "멘토링 삭제", description = "멘토링을 삭제합니다. 멘토링 작성자만 접근할 수 있습니다.") public RsData deleteMentoring( @PathVariable Long mentoringId diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java index 23386e80..9f90fe35 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/dto/request/MentoringRequest.java @@ -16,9 +16,6 @@ public record MentoringRequest( @Schema(description = "멘토링 소개", example = "bio") @NotNull - String bio, - - @Schema(description = "멘토링 썸네일", example = "test.png") - String thumb + String bio ) { } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java index fe166a76..c8ccd00b 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/entity/Mentoring.java @@ -40,10 +40,9 @@ public Mentoring(Mentor mentor, String title, String bio) { this.bio = bio; } - public void update(String title, String bio, List tags, String thumb) { + public void update(String title, String bio, List tags) { this.title = title; this.bio = bio; - this.thumb = thumb; updateTags(tags); } diff --git a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java index 3299038d..b177a08b 100644 --- a/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java +++ b/back/src/main/java/com/back/domain/mentoring/mentoring/service/MentoringService.java @@ -76,17 +76,7 @@ public MentoringResponse createMentoring(MentoringRequest reqDto, MultipartFile mentoringRepository.saveAndFlush(mentoring); - if (thumb != null && !thumb.isEmpty()) { - String imageUrl = null; - try { - String path = "mentoring/" + mentoring.getId(); - imageUrl = s3ImageUploader.upload(thumb, path); - } catch (IOException e) { - throw new ServiceException(ImageErrorCode.IMAGE_UPLOAD_FAILED); - } - - mentoring.updateThumb(imageUrl); - } + uploadThumb(thumb, mentoring); return new MentoringResponse( MentoringDetailDto.from(mentoring), @@ -95,7 +85,7 @@ public MentoringResponse createMentoring(MentoringRequest reqDto, MultipartFile } @Transactional - public MentoringResponse updateMentoring(Long mentoringId, MentoringRequest reqDto, Mentor mentor) { + public MentoringResponse updateMentoring(Long mentoringId, MentoringRequest reqDto, MultipartFile thumb, Mentor mentor) { Mentoring mentoring = mentoringStorage.findMentoring(mentoringId); validateOwner(mentoring, mentor); @@ -103,7 +93,8 @@ public MentoringResponse updateMentoring(Long mentoringId, MentoringRequest reqD List tags = getOrCreateTags(reqDto.tags()); - mentoring.update(reqDto.title(), reqDto.bio(), tags, reqDto.thumb()); + mentoring.update(reqDto.title(), reqDto.bio(), tags); + uploadThumb(thumb, mentoring); return new MentoringResponse( MentoringDetailDto.from(mentoring), @@ -162,6 +153,23 @@ private List createNewTags(List tagNames, Set existingNames } + // ===== 썸네일 ===== + + private void uploadThumb(MultipartFile thumb, Mentoring mentoring) { + if (thumb != null && !thumb.isEmpty()) { + String imageUrl = null; + try { + String path = "mentoring/" + mentoring.getId(); + imageUrl = s3ImageUploader.upload(thumb, path); + } catch (IOException e) { + throw new ServiceException(ImageErrorCode.IMAGE_UPLOAD_FAILED); + } + + mentoring.updateThumb(imageUrl); + } + } + + // ===== 유효성 검사 ===== private void validateOwner(Mentoring mentoring, Mentor mentor) { diff --git a/back/src/main/java/com/back/global/initData/SessionInitData.java b/back/src/main/java/com/back/global/initData/SessionInitData.java index f3b95c3a..54606563 100644 --- a/back/src/main/java/com/back/global/initData/SessionInitData.java +++ b/back/src/main/java/com/back/global/initData/SessionInitData.java @@ -43,7 +43,7 @@ public CommandLineRunner initData() { Mentee mentee = menteeRepository.findByMemberIdWithMember(menteeMember.getId()).orElseThrow(); // 멘토링 생성 - MentoringRequest mentoringRequest = new MentoringRequest("Test Mentoring", Arrays.asList("Java", "Spring"), "This is a test mentoring.", null); + MentoringRequest mentoringRequest = new MentoringRequest("Test Mentoring", Arrays.asList("Java", "Spring"), "This is a test mentoring."); MentoringResponse mentoringResponse = mentoringService.createMentoring(mentoringRequest, null, mentor); // 멘토 슬롯 생성 diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java index c1d62b0c..0a3ea369 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/controller/MentoringControllerTest.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; @@ -236,15 +237,20 @@ void updateMentoringSuccess() throws Exception { MentoringRequest reqDto = new MentoringRequest( "Next.js 멘토링", List.of("Next.js", "React"), - "Next.js를 활용한 프론트 개발 입문", - "https://example.com/thumb.jpg" + "Next.js를 활용한 프론트 개발 입문" + ); + + MockMultipartFile jsonPart = new MockMultipartFile( + "reqDto", + "", + "application/json", + Ut.json.toString(reqDto).getBytes(StandardCharsets.UTF_8) ); ResultActions resultActions = mvc.perform( - put(MENTORING_URL + "/" + mentoring.getId()) + multipart(HttpMethod.PUT, MENTORING_URL + "/" + mentoring.getId()) + .file(jsonPart) .cookie(new Cookie(TOKEN, mentorToken)) - .contentType(MediaType.APPLICATION_JSON) - .content(Ut.json.toString(reqDto)) ) .andDo(print()); @@ -260,8 +266,7 @@ void updateMentoringSuccess() throws Exception { .andExpect(jsonPath("$.data.mentoring.title").value(reqDto.title())) .andExpect(jsonPath("$.data.mentoring.tags[0]").value(reqDto.tags().get(0))) .andExpect(jsonPath("$.data.mentoring.tags[1]").value(reqDto.tags().get(1))) - .andExpect(jsonPath("$.data.mentoring.bio").value(reqDto.bio())) - .andExpect(jsonPath("$.data.mentoring.thumb").value(reqDto.thumb())); + .andExpect(jsonPath("$.data.mentoring.bio").value(reqDto.bio())); } @Test @@ -369,17 +374,21 @@ private ResultActions performUpdateMentoring(Long mentoringId, String token) thr { "title": "Next.js 멘토링", "tags": ["Next.js", "React"], - "bio": "Next.js를 활용한 프론트 개발 입문", - "thumb": "https://example.com/thumb.jpg" + "bio": "Next.js를 활용한 프론트 개발 입문" } """; + MockMultipartFile jsonPart = new MockMultipartFile( + "reqDto", + "", + "application/json", + req.getBytes(StandardCharsets.UTF_8) + ); return mvc .perform( - put(MENTORING_URL + "/" + mentoringId) + multipart(HttpMethod.PUT, MENTORING_URL + "/" + mentoringId) + .file(jsonPart) .cookie(new Cookie(TOKEN, token)) - .contentType(MediaType.APPLICATION_JSON) - .content(req) ) .andDo(print()) .andExpect(handler().handlerType(MentoringController.class)) diff --git a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java index 78e048ad..ac648330 100644 --- a/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java +++ b/back/src/test/java/com/back/domain/mentoring/mentoring/service/MentoringServiceTest.java @@ -72,8 +72,7 @@ void setUp() { request = new MentoringRequest( "Spring Boot 멘토링", List.of("Spring", "Java"), - "Spring Boot를 활용한 백엔드 개발 입문", - "https://example.com/thumb.jpg" + "Spring Boot를 활용한 백엔드 개발 입문" ); } @@ -292,14 +291,13 @@ void updateMentoring() { .thenReturn(mentoring1); // when - MentoringResponse result = mentoringService.updateMentoring(mentoringId, request, mentor1); + MentoringResponse result = mentoringService.updateMentoring(mentoringId, request, null, mentor1); // then assertThat(result).isNotNull(); assertThat(result.mentoring().title()).isEqualTo(request.title()); assertThat(result.mentoring().bio()).isEqualTo(request.bio()); assertThat(result.mentoring().tags()).isEqualTo(request.tags()); - assertThat(result.mentoring().thumb()).isEqualTo(request.thumb()); verify(mentoringStorage).findMentoring(mentoringId); verify(tagRepository).findByNameIn(request.tags()); @@ -315,7 +313,7 @@ void throwExceptionWhenNotOwner() { .thenReturn(mentoring1); // when & then - assertThatThrownBy(() -> mentoringService.updateMentoring(mentoringId, request, mentor2)) + assertThatThrownBy(() -> mentoringService.updateMentoring(mentoringId, request,null, mentor2)) .isInstanceOf(ServiceException.class) .hasFieldOrPropertyWithValue("resultCode", MentoringErrorCode.FORBIDDEN_NOT_OWNER.getCode()); }