Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

내 프로필 사진 변경 API 구현 #86

Merged
merged 15 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,17 @@ private static InputStream getInputStreamFromMultipartFile(MultipartFile multipa
throw new MultipartFileNotReadableException(ex);
}
}

/**
* S3 bucket에서 파일을 삭제한다.
*
* @param storedFileName 삭제할 파일의 이름 (key of bucket object)
*/
@Override
public void deleteFile(String storedFileName) {
s3Client.deleteObject(
new DeleteObjectRequest(awsProperties.s3().bucketName(), storedFileName)
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,11 @@ public interface FileService {
* @param storedFileNames 삭제할 파일들의 이름 목록
*/
void deleteAll(Collection<String> storedFileNames);

/**
* 파일을 삭제한다.
*
* @param storedFileName 삭제할 파일의 이름
*/
void deleteFile(String storedFileName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.net.URI;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
Expand All @@ -13,7 +14,9 @@
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.ajou.hertz.common.auth.UserPrincipal;
import com.ajou.hertz.common.validator.PhoneNumber;
Expand Down Expand Up @@ -117,6 +120,24 @@ public ResponseEntity<UserResponse> signUpV1(
.body(UserResponse.from(userCreated));
}

@Operation(
summary = "프로필 이미지 변경",
description = "프로필 이미지를 변경합니다.",
security = @SecurityRequirement(name = "access-token")
)
@PutMapping(
value = "/me/profile-images",
headers = API_VERSION_HEADER_NAME + "=" + 1,
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
Wo-ogie marked this conversation as resolved.
Show resolved Hide resolved
public UserResponse updateProfileImageV1(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@RequestPart MultipartFile profileImage
) {
UserDto userUpdated = userCommandService.updateUserProfileImage(userPrincipal.getUserId(), profileImage);
return UserResponse.from(userUpdated);
}

@Operation(
summary = "연락 수단 변경",
description = "연락 수단을 변경합니다.",
Expand All @@ -131,4 +152,5 @@ public UserResponse updateContactLinkV1(
updateContactLinkRequest.getContactLink());
return UserResponse.from(userUpdated);
}
}
}

4 changes: 4 additions & 0 deletions src/main/java/com/ajou/hertz/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ public static User create(
);
}

public void changeProfileImageUrl(String profileImageUrl) {
this.profileImageUrl = profileImageUrl;
}

public void changeContactLink(String contactLink) {
this.contactLink = contactLink;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class UserProfileImage extends FileEntity {
@Column(name = "user_profile_image_id", nullable = false)
private Long id;

@JoinColumn(name = "user_id", nullable = false)
@JoinColumn(name = "user_id", nullable = false, unique = true)
@OneToOne(fetch = FetchType.LAZY)
private User user;

Expand All @@ -33,4 +33,14 @@ private UserProfileImage(Long id, User user, String originalName, String storedN
this.id = id;
this.user = user;
}

public static UserProfileImage create(
User user,
String originalName,
String storedName,
String url
) {
return new UserProfileImage(null, user, originalName, storedName, url);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ajou.hertz.domain.user.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

import com.ajou.hertz.domain.user.entity.UserProfileImage;

public interface UserProfileImageRepository extends JpaRepository<UserProfileImage, Long> {
Optional<UserProfileImage> findByUser_Id(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
import com.ajou.hertz.common.properties.HertzProperties;
Expand All @@ -29,6 +30,7 @@ public class UserCommandService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final HertzProperties hertzProperties;
private final UserProfileImageCommandService userProfileImageCommandService;

/**
* 새로운 회원을 등록한다.
Expand Down Expand Up @@ -130,13 +132,28 @@ private String generateRandom16CharString() {
.substring(0, 16);
}

/**
* 유저의 프로필 이미지를 업데이트합니다.
*
* @param userId 유저의 ID
* @param profileImage 변경할 프로필 이미지
*
* @return 변경된 유저 정보
*/
public UserDto updateUserProfileImage(Long userId, MultipartFile profileImage) {
User user = userQueryService.getById(userId);
String newProfileImageUrl = userProfileImageCommandService.updateProfileImage(user, profileImage);
user.changeProfileImageUrl(newProfileImageUrl);
return UserDto.from(user);
}

/**
*연락 수단을 변경합니다.
*
* @param userId 유저의 ID
* @param contactLink 변경할 연락 수단
*
*@return 변경된 유저 정보
* @return 변경된 유저 정보
*/
public UserDto updateContactLink(Long userId, String contactLink) {
User user = userQueryService.getById(userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.ajou.hertz.domain.user.service;

import java.util.Optional;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import com.ajou.hertz.common.file.dto.FileDto;
import com.ajou.hertz.common.file.service.FileService;
import com.ajou.hertz.domain.user.entity.User;
import com.ajou.hertz.domain.user.entity.UserProfileImage;
import com.ajou.hertz.domain.user.repository.UserProfileImageRepository;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Transactional
@Service
public class UserProfileImageCommandService {

private final FileService fileService;
private final UserProfileImageRepository userProfileImageRepository;

private static final String USER_PROFILE_IMAGE_UPLOAD_PATH = "user-profile-images/";

/**
* 유저의 프로필 이미지를 업데이트한다.
*
* @param user 프로필 이미지를 업데이트할 유저
* @param newProfileImage 새로운 프로필 이미지
*
* @return 새로운 프로필 이미지 URL
*/
public String updateProfileImage(User user, MultipartFile newProfileImage) {
deleteOldProfileImage(user.getId());
userProfileImageRepository.flush();
UserProfileImage newUserProfileImage = uploadNewProfileImage(user, newProfileImage);
return newUserProfileImage.getUrl();
}

/**
* 유저의 프로필 이미지를 삭제한다.
*
* @param userId 프로필 이미지를 삭제할 유저의 id
*/
private void deleteOldProfileImage(Long userId) {
Optional<UserProfileImage> optionalOldProfileImage = userProfileImageRepository.findByUser_Id(userId);
if (optionalOldProfileImage.isPresent()) {
UserProfileImage oldProfileImage = optionalOldProfileImage.get();
userProfileImageRepository.delete(oldProfileImage);
fileService.deleteFile(oldProfileImage.getStoredName());
}
}

/**
* 새로운 프로필 이미지를 업로드한다.
* @param user 프로필 이미지를 업데이트할 유저
* @param newProfileImage 새로운 프로필 이미지
*
* @return 새로운 프로필 이미지 entity
*/
private UserProfileImage uploadNewProfileImage(User user, MultipartFile newProfileImage) {
FileDto uploadedFile = fileService.uploadFile(newProfileImage, USER_PROFILE_IMAGE_UPLOAD_PATH);
UserProfileImage newUserProfileImage = UserProfileImage.create(
user,
uploadedFile.getOriginalName(),
uploadedFile.getStoredName(),
uploadedFile.getUrl());
userProfileImageRepository.save(newUserProfileImage);
return newUserProfileImage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ void setUpMockProperties() {
verifyEveryMocksShouldHaveNoMoreInteractions();
}

@Test
void S3에_저장된_파일의_이름이_주어지고_파일을_S3_버킷에서_삭제한다() throws Exception {
// given
String storedFileName = "stored-file-name";
willDoNothing().given(s3Client).deleteObject(any(DeleteObjectRequest.class));

// when
sut.deleteFile(storedFileName);

// then
then(s3Client).should().deleteObject(any(DeleteObjectRequest.class));
verifyEveryMocksShouldHaveNoMoreInteractions();
}

private void verifyEveryMocksShouldHaveNoMoreInteractions() {
then(s3Client).shouldHaveNoMoreInteractions();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import org.springframework.test.web.servlet.MockMvc;
Expand Down Expand Up @@ -212,6 +213,38 @@ public void securitySetUp() throws Exception {
verifyEveryMocksShouldHaveNoMoreInteractions();
}

@Test
void 주어진_id와_변경할_프로필_이미지로_프로필_이미지를_변경한다() throws Exception {
// given
long userId = 1L;
MockMultipartFile profileImage = new MockMultipartFile(
"profileImage",
"test.jpg",
"image/jpeg",
"test".getBytes()
);
UserDetails userDetails = createTestUser(userId);
UserDto expectedResult = createUserDto(userId);

given(userCommandService.updateUserProfileImage(userId, profileImage)).willReturn(expectedResult);

// when & then
mvc.perform(
multipart("/api/users/me/profile-images")
.file(profileImage)
.header(API_VERSION_HEADER_NAME, 1)
.with(user(userDetails))
.with(request -> {
request.setMethod("PUT");
return request;
})
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.profileImageUrl").value(expectedResult.getProfileImageUrl()));
then(userCommandService).should().updateUserProfileImage(userId, profileImage);
verifyEveryMocksShouldHaveNoMoreInteractions();
}

@Test
void 주어진_연락수단을_새로운_연락수단으로_변경한다() throws Exception {
// given
Expand Down Expand Up @@ -281,5 +314,4 @@ private UserDto createUserDto() throws Exception {
private UserDetails createTestUser(Long userId) throws Exception {
return new UserPrincipal(createUserDto(userId));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import org.springframework.web.multipart.MultipartFile;

import com.ajou.hertz.common.file.service.FileService;
import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
import com.ajou.hertz.common.properties.HertzProperties;
import com.ajou.hertz.domain.user.constant.Gender;
Expand All @@ -33,6 +36,7 @@
import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException;
import com.ajou.hertz.domain.user.repository.UserRepository;
import com.ajou.hertz.domain.user.service.UserCommandService;
import com.ajou.hertz.domain.user.service.UserProfileImageCommandService;
import com.ajou.hertz.domain.user.service.UserQueryService;
import com.ajou.hertz.util.ReflectionUtils;

Expand All @@ -55,6 +59,12 @@ class UserCommandServiceTest {
@Mock
private HertzProperties hertzProperties;

@Mock
private FileService fileService;

@Mock
private UserProfileImageCommandService userProfileImageCommandService;

@BeforeTestMethod
public void setUp() {
given(hertzProperties.userDefaultProfileImageUrl()).willReturn("https://user-default-profile-image");
Expand Down Expand Up @@ -172,6 +182,30 @@ static Stream<Arguments> testDataForCreateNewUserWithKakao() throws Exception {
assertThat(t).isInstanceOf(UserKakaoUidDuplicationException.class);
}

@Test
void 주어진_유저_ID와_이미지_URL로_유저의_프로필_이미지를_업데이트한다() throws Exception {
// Given
Long userId = 1L;
User user = createUser(userId, "password", "kakaoUid");
String newProfileImageUrl = "https://new-profile-image-url";

MultipartFile profileImage = new MockMultipartFile("file", "test.jpg", "image/jpeg",
"test image content".getBytes());

given(userQueryService.getById(userId)).willReturn(user);
given(userProfileImageCommandService.updateProfileImage(user, profileImage)).willReturn(
newProfileImageUrl);

// When
UserDto result = sut.updateUserProfileImage(userId, profileImage);

// Then
then(userQueryService).should().getById(userId);
then(userProfileImageCommandService).should().updateProfileImage(user, profileImage);
assertThat(result.getProfileImageUrl()).isEqualTo(newProfileImageUrl);
verifyEveryMocksShouldHaveNoMoreInteractions();
}

@Test
void 주어진_유저_ID와_연락_수단으로_연락_수단을_변경한다() throws Exception {
// given
Expand Down Expand Up @@ -209,6 +243,8 @@ private void verifyEveryMocksShouldHaveNoMoreInteractions() {
then(userQueryService).shouldHaveNoMoreInteractions();
then(userRepository).shouldHaveNoMoreInteractions();
then(passwordEncoder).shouldHaveNoMoreInteractions();
then(fileService).shouldHaveNoMoreInteractions();
then(userProfileImageCommandService).shouldHaveNoMoreInteractions();
}

private static User createUser(Long id, String password, String kakaoUid, Gender gender) throws Exception {
Expand Down
Loading
Loading