diff --git a/perfume-api/src/docs/asciidoc/index.adoc b/perfume-api/src/docs/asciidoc/index.adoc index 09de5704..ebc0dcf0 100644 --- a/perfume-api/src/docs/asciidoc/index.adoc +++ b/perfume-api/src/docs/asciidoc/index.adoc @@ -87,6 +87,13 @@ include::{snippets}/update-user-profile/http-response.adoc[] === 실패 응답 include::{snippets}/update-user-profile-failed/http-response.adoc[] +== 프로필 사진 수정 +=== 요청 +include::{snippets}/update-user-profile-pic/http-request.adoc[] + +=== 응답 +include::{snippets}/update-user-profile-pic/http-response.adoc[] + == 로그인 중인 회원 탈퇴 === 요청 include::{snippets}/leave-user/http-request.adoc[] diff --git a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/SaveFileUseCase.java b/perfume-api/src/main/java/io/perfume/api/file/application/port/in/SaveFileUseCase.java deleted file mode 100644 index 383c672c..00000000 --- a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/SaveFileUseCase.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.perfume.api.file.application.port.in; - -import io.perfume.api.file.application.port.in.dto.MultiFileResponseDto; -import io.perfume.api.file.application.port.in.dto.SaveFileResult; -import java.time.LocalDateTime; -import java.util.List; -import org.springframework.security.core.userdetails.User; -import org.springframework.web.multipart.MultipartFile; - -public interface SaveFileUseCase { - - SaveFileResult singleFileUpload(Long userId, MultipartFile file, LocalDateTime now); - - MultiFileResponseDto multiFileUpload(User user, List files, LocalDateTime now); -} diff --git a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/MultiFileResponseDto.java b/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/MultiFileResponseDto.java deleted file mode 100644 index 2ceb40a0..00000000 --- a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/MultiFileResponseDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.perfume.api.file.application.port.in.dto; - -import java.util.List; - -public record MultiFileResponseDto(List saveFiles) {} diff --git a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/SaveFileResult.java b/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/SaveFileResult.java deleted file mode 100644 index 8acc1525..00000000 --- a/perfume-api/src/main/java/io/perfume/api/file/application/port/in/dto/SaveFileResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.perfume.api.file.application.port.in.dto; - -import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; - -public record SaveFileResult( - @NotNull String url, @NotNull Long userId, @NotNull Long fileId, LocalDateTime now) {} diff --git a/perfume-api/src/main/java/io/perfume/api/file/application/service/SaveFileService.java b/perfume-api/src/main/java/io/perfume/api/file/application/service/SaveFileService.java deleted file mode 100644 index 25975299..00000000 --- a/perfume-api/src/main/java/io/perfume/api/file/application/service/SaveFileService.java +++ /dev/null @@ -1,62 +0,0 @@ -package io.perfume.api.file.application.service; - -import io.perfume.api.file.application.exception.SaveFileNotFoundException; -import io.perfume.api.file.application.port.in.SaveFileUseCase; -import io.perfume.api.file.application.port.in.dto.MultiFileResponseDto; -import io.perfume.api.file.application.port.in.dto.SaveFileResult; -import io.perfume.api.file.application.port.out.FileRepository; -import io.perfume.api.file.domain.File; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import org.springframework.security.core.userdetails.User; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@Service -@Transactional -public class SaveFileService implements SaveFileUseCase { - - private final FileRepository fileRepository; - - private final String cdnUrl = ""; - - public SaveFileService(FileRepository fileRepository) { - this.fileRepository = fileRepository; - } - - @Override - public SaveFileResult singleFileUpload(Long userId, MultipartFile file, LocalDateTime now) { - String URL = cdnUrl + file.getOriginalFilename(); - if (file != null && !file.isEmpty()) { - File saveFile = fileRepository.save(File.createFile(URL, userId, now)); - return new SaveFileResult(saveFile.getUrl(), userId, saveFile.getId(), now); - } else { - throw new SaveFileNotFoundException(); - } - } - - @Override - public MultiFileResponseDto multiFileUpload( - User user, List files, LocalDateTime now) { - List saveFiles = new ArrayList<>(); - if (files != null && !files.isEmpty()) { - for (MultipartFile file : files) { - String URL = cdnUrl + file.getOriginalFilename(); - saveFiles.add(File.createFile(URL, Long.parseLong(user.getUsername()), now)); - } - return toDto(fileRepository.saveAll(saveFiles), now); - } else { - throw new SaveFileNotFoundException(); - } - } - - private MultiFileResponseDto toDto(List saveFiles, LocalDateTime now) { - List results = new ArrayList<>(); - for (File file : saveFiles) { - results.add(new SaveFileResult(file.getUrl(), file.getUserId(), file.getId(), now)); - } - return new MultiFileResponseDto(results); - } -} diff --git a/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/SetUserProfileController.java b/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/SetUserProfileController.java deleted file mode 100644 index a0f93bac..00000000 --- a/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/SetUserProfileController.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.perfume.api.user.adapter.in.http; - -import io.perfume.api.user.application.port.in.SetUserProfileUseCase; -import java.time.LocalDateTime; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.User; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping("/v1/user") -public class SetUserProfileController { - - private final SetUserProfileUseCase setUserProfileUseCase; - - public SetUserProfileController(SetUserProfileUseCase setUserProfileUseCase) { - this.setUserProfileUseCase = setUserProfileUseCase; - } - - @PutMapping("/test-setPicture-endpoint") - public ResponseEntity setProfilePicture( - @AuthenticationPrincipal User user, @RequestPart MultipartFile image) { - LocalDateTime now = LocalDateTime.now(); - setUserProfileUseCase.setUserProfilePicture(user.getUsername(), image, now); - return ResponseEntity.ok().build(); - } -} diff --git a/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/UserSupportController.java b/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/UserSupportController.java index 9499de23..fe78cf9d 100644 --- a/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/UserSupportController.java +++ b/perfume-api/src/main/java/io/perfume/api/user/adapter/in/http/UserSupportController.java @@ -1,36 +1,25 @@ package io.perfume.api.user.adapter.in.http; import io.perfume.api.file.application.port.in.FindFileUseCase; -import io.perfume.api.user.adapter.in.http.dto.LeaveUserDto; -import io.perfume.api.user.adapter.in.http.dto.UpdateEmailRequestDto; -import io.perfume.api.user.adapter.in.http.dto.UpdatePasswordRequestDto; -import io.perfume.api.user.adapter.in.http.dto.UpdateProfileRequestDto; -import io.perfume.api.user.adapter.in.http.dto.UserProfileDto; +import io.perfume.api.user.adapter.in.http.dto.*; import io.perfume.api.user.adapter.in.http.exception.UserNotAuthenticatedException; -import io.perfume.api.user.application.port.in.FindEncryptedUsernameUseCase; -import io.perfume.api.user.application.port.in.FindUserUseCase; -import io.perfume.api.user.application.port.in.LeaveUserUseCase; -import io.perfume.api.user.application.port.in.SendResetPasswordMailUseCase; -import io.perfume.api.user.application.port.in.UpdateAccountUseCase; +import io.perfume.api.user.application.port.in.*; import io.perfume.api.user.application.port.in.dto.UpdateEmailCommand; import io.perfume.api.user.application.port.in.dto.UpdatePasswordCommand; import io.perfume.api.user.application.port.in.dto.UpdateProfileCommand; import io.perfume.api.user.application.port.in.dto.UserProfileResult; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import java.io.IOException; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.User; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor @@ -42,6 +31,7 @@ public class UserSupportController { private final FindFileUseCase findFileUseCase; private final SendResetPasswordMailUseCase resetPasswordUserCase; private final UpdateAccountUseCase updateAccountUseCase; + private final UpdateProfilePicUseCase updateProfilePicUseCase; private final LeaveUserUseCase leaveUserUseCase; @@ -116,6 +106,27 @@ public void updateProfileByUser( updateAccountUseCase.updateUserProfile(updateProfileCommand); } + @PatchMapping("/user/profile-pic") + @PreAuthorize("isAuthenticated()") + public void updateProfilePic(@AuthenticationPrincipal User user, final MultipartFile file) { + var now = LocalDateTime.now(); + checkAuthenticatedUser(user); + long userId = parseUserId(user); + updateProfilePicUseCase.update(userId, getFileContent(file), now); + } + + private long parseUserId(User user) { + return Long.parseLong(user.getUsername()); + } + + private byte[] getFileContent(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + return null; + } + } + private void checkAuthenticatedUser(User user) { if (user == null || user.getUsername() == null) { throw new UserNotAuthenticatedException(); diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/port/in/SetUserProfileUseCase.java b/perfume-api/src/main/java/io/perfume/api/user/application/port/in/SetUserProfileUseCase.java deleted file mode 100644 index 02ca00dc..00000000 --- a/perfume-api/src/main/java/io/perfume/api/user/application/port/in/SetUserProfileUseCase.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.perfume.api.user.application.port.in; - -import java.time.LocalDateTime; -import org.springframework.web.multipart.MultipartFile; - -// 독립적인 기능이라면 usecase를 별도로 분리? -public interface SetUserProfileUseCase { - - void setUserProfilePicture(String userId, MultipartFile image, LocalDateTime now); -} diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/port/in/UpdateProfilePicUseCase.java b/perfume-api/src/main/java/io/perfume/api/user/application/port/in/UpdateProfilePicUseCase.java new file mode 100644 index 00000000..7380ebc2 --- /dev/null +++ b/perfume-api/src/main/java/io/perfume/api/user/application/port/in/UpdateProfilePicUseCase.java @@ -0,0 +1,9 @@ +package io.perfume.api.user.application.port.in; + +import io.perfume.api.user.application.port.in.dto.UpdateProfilePicResult; +import java.time.LocalDateTime; + +public interface UpdateProfilePicUseCase { + + UpdateProfilePicResult update(Long userId, byte[] imageFileContent, LocalDateTime now); +} diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/port/in/dto/UpdateProfilePicResult.java b/perfume-api/src/main/java/io/perfume/api/user/application/port/in/dto/UpdateProfilePicResult.java new file mode 100644 index 00000000..5695ecdc --- /dev/null +++ b/perfume-api/src/main/java/io/perfume/api/user/application/port/in/dto/UpdateProfilePicResult.java @@ -0,0 +1,10 @@ +package io.perfume.api.user.application.port.in.dto; + +import io.perfume.api.file.application.port.in.dto.FileResult; + +public record UpdateProfilePicResult(Long userId, Long fileId, String url) { + + public static UpdateProfilePicResult from(Long userId, FileResult fileResult) { + return new UpdateProfilePicResult(userId, fileResult.id(), fileResult.url()); + } +} diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/service/SetUserProfileService.java b/perfume-api/src/main/java/io/perfume/api/user/application/service/SetUserProfileService.java deleted file mode 100644 index b575fa43..00000000 --- a/perfume-api/src/main/java/io/perfume/api/user/application/service/SetUserProfileService.java +++ /dev/null @@ -1,44 +0,0 @@ -package io.perfume.api.user.application.service; - -import io.perfume.api.file.application.port.in.dto.SaveFileResult; -import io.perfume.api.file.application.service.SaveFileService; -import io.perfume.api.user.application.exception.NotFoundUserException; -import io.perfume.api.user.application.port.in.SetUserProfileUseCase; -import io.perfume.api.user.application.port.out.UserQueryRepository; -import io.perfume.api.user.application.port.out.UserRepository; -import io.perfume.api.user.domain.User; -import java.time.LocalDateTime; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@Service -@Transactional -public class SetUserProfileService implements SetUserProfileUseCase { - - private final UserQueryRepository userQueryRepository; - private final UserRepository userRepository; - private final SaveFileService fileUploadService; - - public SetUserProfileService( - UserQueryRepository userQueryRepository, - UserRepository userRepository, - SaveFileService fileUploadService) { - this.userQueryRepository = userQueryRepository; - this.userRepository = userRepository; - this.fileUploadService = fileUploadService; - } - - @Override - public void setUserProfilePicture(String userId, MultipartFile image, LocalDateTime now) { - if (userId == null) { - new NotFoundUserException(); - } else { - SaveFileResult saveFileResult = - fileUploadService.singleFileUpload(Long.parseLong(userId), image, now); - User user = userQueryRepository.loadUser(Long.parseLong(userId)).get(); - user.updateThumbnailId(saveFileResult.fileId()); - userRepository.save(user); - } - } -} diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/service/SupportUserService.java b/perfume-api/src/main/java/io/perfume/api/user/application/service/SupportUserService.java index 65b056b4..bf456702 100644 --- a/perfume-api/src/main/java/io/perfume/api/user/application/service/SupportUserService.java +++ b/perfume-api/src/main/java/io/perfume/api/user/application/service/SupportUserService.java @@ -3,6 +3,7 @@ import encryptor.OneWayEncryptor; import io.perfume.api.user.application.exception.FailedToLeaveException; import io.perfume.api.user.application.exception.NotFoundUserException; +import io.perfume.api.user.application.exception.UserNotFoundException; import io.perfume.api.user.application.port.in.FindEncryptedUsernameUseCase; import io.perfume.api.user.application.port.in.LeaveUserUseCase; import io.perfume.api.user.application.port.in.SendResetPasswordMailUseCase; @@ -59,24 +60,16 @@ public String findEncryptedUsername(String email) { @Override public void leave(Long userId) { - try { - - User user = userQueryRepository.loadUser(userId).orElseThrow(NotFoundUserException::new); - + User user = + userQueryRepository.loadUser(userId).orElseThrow(() -> new UserNotFoundException(userId)); LocalDateTime now = LocalDateTime.now(); - user.softDelete(now); - userRepository.save(user); - } catch (NotFoundUserException e) { - FailedToLeaveException failedLeaveException = new FailedToLeaveException("회원탈퇴 실패", "user id : " + userId + ", 회원탈퇴 실패"); - failedLeaveException.initCause(e); - throw failedLeaveException; } } diff --git a/perfume-api/src/main/java/io/perfume/api/user/application/service/UpdateProfilePicService.java b/perfume-api/src/main/java/io/perfume/api/user/application/service/UpdateProfilePicService.java new file mode 100644 index 00000000..5ce80925 --- /dev/null +++ b/perfume-api/src/main/java/io/perfume/api/user/application/service/UpdateProfilePicService.java @@ -0,0 +1,44 @@ +package io.perfume.api.user.application.service; + +import io.perfume.api.file.application.port.in.dto.FileResult; +import io.perfume.api.file.application.service.FileService; +import io.perfume.api.user.application.exception.UserNotFoundException; +import io.perfume.api.user.application.port.in.UpdateProfilePicUseCase; +import io.perfume.api.user.application.port.in.dto.UpdateProfilePicResult; +import io.perfume.api.user.application.port.out.UserQueryRepository; +import io.perfume.api.user.application.port.out.UserRepository; +import io.perfume.api.user.domain.User; +import java.time.LocalDateTime; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class UpdateProfilePicService implements UpdateProfilePicUseCase { + + private final UserQueryRepository userQueryRepository; + private final UserRepository userRepository; + private final FileService fileUploadService; + + public UpdateProfilePicService( + UserQueryRepository userQueryRepository, + UserRepository userRepository, + FileService fileUploadService) { + this.userQueryRepository = userQueryRepository; + this.userRepository = userRepository; + this.fileUploadService = fileUploadService; + } + + @Override + @Transactional + public UpdateProfilePicResult update(Long userId, byte[] imageFileContent, LocalDateTime now) { + FileResult fileResult = fileUploadService.uploadFile(imageFileContent, userId, now); + User user = + userQueryRepository + .findUserById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + user.updateThumbnailId(fileResult.id()); + userRepository.save(user); + return UpdateProfilePicResult.from(userId, fileResult); + } +} diff --git a/perfume-api/src/test/java/io/perfume/api/user/adapter/in/http/UserSupportControllerTest.java b/perfume-api/src/test/java/io/perfume/api/user/adapter/in/http/UserSupportControllerTest.java index 13ea52a7..420e8904 100644 --- a/perfume-api/src/test/java/io/perfume/api/user/adapter/in/http/UserSupportControllerTest.java +++ b/perfume-api/src/test/java/io/perfume/api/user/adapter/in/http/UserSupportControllerTest.java @@ -6,9 +6,10 @@ import static org.mockito.Mockito.doNothing; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +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.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -16,11 +17,7 @@ import io.perfume.api.user.adapter.in.http.dto.UpdateEmailRequestDto; import io.perfume.api.user.adapter.in.http.dto.UpdatePasswordRequestDto; import io.perfume.api.user.adapter.in.http.dto.UpdateProfileRequestDto; -import io.perfume.api.user.application.port.in.FindEncryptedUsernameUseCase; -import io.perfume.api.user.application.port.in.FindUserUseCase; -import io.perfume.api.user.application.port.in.LeaveUserUseCase; -import io.perfume.api.user.application.port.in.SendResetPasswordMailUseCase; -import io.perfume.api.user.application.port.in.UpdateAccountUseCase; +import io.perfume.api.user.application.port.in.*; import io.perfume.api.user.application.port.in.dto.UserProfileResult; import java.time.LocalDate; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +29,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; @@ -54,6 +52,7 @@ class UserSupportControllerTest { @MockBean private UpdateAccountUseCase updateAccountUseCase; @MockBean private FindEncryptedUsernameUseCase findEncryptedUsernameUseCase; @MockBean private SendResetPasswordMailUseCase resetPasswordUserCase; + @MockBean private UpdateProfilePicUseCase updateProfilePicUseCase; @BeforeEach void setUp( @@ -208,6 +207,34 @@ void updateProfile() throws Exception { .description("성별 (male | female | other)만 입력 가능")))); } + @Test + @DisplayName("유저의 프로필 사진을 업데이트한다.") + @WithMockUser(username = "1", roles = "USER") + void updateProfilePic() throws Exception { + // given + final MockMultipartFile profilePic = + new MockMultipartFile("file", "test.txt", "text/plain", "file".getBytes()); + + // when & then + mockMvc + .perform( + multipart("/v1/user/profile-pic") + .file(profilePic) + .accept(MediaType.APPLICATION_JSON) + .characterEncoding("UTF-8") + .contentType(MediaType.MULTIPART_FORM_DATA) + .with( + request -> { + request.setMethod("PATCH"); + return request; + })) + .andExpect(status().isOk()) + .andDo( + document( + "update-user-profile-pic", + requestParts(partWithName("file").description("업로드할 프로필 사진")))); + } + @Test @DisplayName("로그인 상태가 아니라면 유저의 프로필을 업데이트할 수 없다.") void failToUpdateProfile() throws Exception {