Skip to content

Commit c81bde4

Browse files
authored
Merge pull request #86 from Ajou-Hertz/feature/#73-edit-profile-image
내 프로필 사진 변경 API 구현
2 parents 9756df4 + d8ef54d commit c81bde4

File tree

12 files changed

+378
-4
lines changed

12 files changed

+378
-4
lines changed

src/main/java/com/ajou/hertz/common/file/service/AmazonS3FileService.java

+13
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,17 @@ private static InputStream getInputStreamFromMultipartFile(MultipartFile multipa
130130
throw new MultipartFileNotReadableException(ex);
131131
}
132132
}
133+
134+
/**
135+
* S3 bucket에서 파일을 삭제한다.
136+
*
137+
* @param storedFileName 삭제할 파일의 이름 (key of bucket object)
138+
*/
139+
@Override
140+
public void deleteFile(String storedFileName) {
141+
s3Client.deleteObject(
142+
new DeleteObjectRequest(awsProperties.s3().bucketName(), storedFileName)
143+
);
144+
}
145+
133146
}

src/main/java/com/ajou/hertz/common/file/service/FileService.java

+7
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,11 @@ public interface FileService {
3333
* @param storedFileNames 삭제할 파일들의 이름 목록
3434
*/
3535
void deleteAll(Collection<String> storedFileNames);
36+
37+
/**
38+
* 파일을 삭제한다.
39+
*
40+
* @param storedFileName 삭제할 파일의 이름
41+
*/
42+
void deleteFile(String storedFileName);
3643
}

src/main/java/com/ajou/hertz/domain/user/controller/UserController.java

+23-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import java.net.URI;
66

7+
import org.springframework.http.MediaType;
78
import org.springframework.http.ResponseEntity;
89
import org.springframework.security.core.annotation.AuthenticationPrincipal;
910
import org.springframework.validation.annotation.Validated;
@@ -13,7 +14,9 @@
1314
import org.springframework.web.bind.annotation.RequestBody;
1415
import org.springframework.web.bind.annotation.RequestMapping;
1516
import org.springframework.web.bind.annotation.RequestParam;
17+
import org.springframework.web.bind.annotation.RequestPart;
1618
import org.springframework.web.bind.annotation.RestController;
19+
import org.springframework.web.multipart.MultipartFile;
1720

1821
import com.ajou.hertz.common.auth.UserPrincipal;
1922
import com.ajou.hertz.common.validator.PhoneNumber;
@@ -117,6 +120,24 @@ public ResponseEntity<UserResponse> signUpV1(
117120
.body(UserResponse.from(userCreated));
118121
}
119122

123+
@Operation(
124+
summary = "프로필 이미지 변경",
125+
description = "프로필 이미지를 변경합니다.",
126+
security = @SecurityRequirement(name = "access-token")
127+
)
128+
@PutMapping(
129+
value = "/me/profile-images",
130+
headers = API_VERSION_HEADER_NAME + "=" + 1,
131+
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
132+
)
133+
public UserResponse updateProfileImageV1(
134+
@AuthenticationPrincipal UserPrincipal userPrincipal,
135+
@RequestPart MultipartFile profileImage
136+
) {
137+
UserDto userUpdated = userCommandService.updateUserProfileImage(userPrincipal.getUserId(), profileImage);
138+
return UserResponse.from(userUpdated);
139+
}
140+
120141
@Operation(
121142
summary = "연락 수단 변경",
122143
description = "연락 수단을 변경합니다.",
@@ -131,4 +152,5 @@ public UserResponse updateContactLinkV1(
131152
updateContactLinkRequest.getContactLink());
132153
return UserResponse.from(userUpdated);
133154
}
134-
}
155+
}
156+

src/main/java/com/ajou/hertz/domain/user/entity/User.java

+4
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ public static User create(
101101
);
102102
}
103103

104+
public void changeProfileImageUrl(String profileImageUrl) {
105+
this.profileImageUrl = profileImageUrl;
106+
}
107+
104108
public void changeContactLink(String contactLink) {
105109
this.contactLink = contactLink;
106110
}

src/main/java/com/ajou/hertz/domain/user/entity/UserProfileImage.java

+11-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public class UserProfileImage extends FileEntity {
2424
@Column(name = "user_profile_image_id", nullable = false)
2525
private Long id;
2626

27-
@JoinColumn(name = "user_id", nullable = false)
27+
@JoinColumn(name = "user_id", nullable = false, unique = true)
2828
@OneToOne(fetch = FetchType.LAZY)
2929
private User user;
3030

@@ -33,4 +33,14 @@ private UserProfileImage(Long id, User user, String originalName, String storedN
3333
this.id = id;
3434
this.user = user;
3535
}
36+
37+
public static UserProfileImage create(
38+
User user,
39+
String originalName,
40+
String storedName,
41+
String url
42+
) {
43+
return new UserProfileImage(null, user, originalName, storedName, url);
44+
}
45+
3646
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ajou.hertz.domain.user.repository;
2+
3+
import java.util.Optional;
4+
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
7+
import com.ajou.hertz.domain.user.entity.UserProfileImage;
8+
9+
public interface UserProfileImageRepository extends JpaRepository<UserProfileImage, Long> {
10+
Optional<UserProfileImage> findByUser_Id(Long userId);
11+
}

src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java

+18-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.springframework.stereotype.Service;
77
import org.springframework.transaction.annotation.Transactional;
88
import org.springframework.util.StringUtils;
9+
import org.springframework.web.multipart.MultipartFile;
910

1011
import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
1112
import com.ajou.hertz.common.properties.HertzProperties;
@@ -29,6 +30,7 @@ public class UserCommandService {
2930
private final UserRepository userRepository;
3031
private final PasswordEncoder passwordEncoder;
3132
private final HertzProperties hertzProperties;
33+
private final UserProfileImageCommandService userProfileImageCommandService;
3234

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

135+
/**
136+
* 유저의 프로필 이미지를 업데이트합니다.
137+
*
138+
* @param userId 유저의 ID
139+
* @param profileImage 변경할 프로필 이미지
140+
*
141+
* @return 변경된 유저 정보
142+
*/
143+
public UserDto updateUserProfileImage(Long userId, MultipartFile profileImage) {
144+
User user = userQueryService.getById(userId);
145+
String newProfileImageUrl = userProfileImageCommandService.updateProfileImage(user, profileImage);
146+
user.changeProfileImageUrl(newProfileImageUrl);
147+
return UserDto.from(user);
148+
}
149+
133150
/**
134151
*연락 수단을 변경합니다.
135152
*
136153
* @param userId 유저의 ID
137154
* @param contactLink 변경할 연락 수단
138155
*
139-
*@return 변경된 유저 정보
156+
* @return 변경된 유저 정보
140157
*/
141158
public UserDto updateContactLink(Long userId, String contactLink) {
142159
User user = userQueryService.getById(userId);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.ajou.hertz.domain.user.service;
2+
3+
import java.util.Optional;
4+
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
import org.springframework.web.multipart.MultipartFile;
8+
9+
import com.ajou.hertz.common.file.dto.FileDto;
10+
import com.ajou.hertz.common.file.service.FileService;
11+
import com.ajou.hertz.domain.user.entity.User;
12+
import com.ajou.hertz.domain.user.entity.UserProfileImage;
13+
import com.ajou.hertz.domain.user.repository.UserProfileImageRepository;
14+
15+
import lombok.RequiredArgsConstructor;
16+
17+
@RequiredArgsConstructor
18+
@Transactional
19+
@Service
20+
public class UserProfileImageCommandService {
21+
22+
private final FileService fileService;
23+
private final UserProfileImageRepository userProfileImageRepository;
24+
25+
private static final String USER_PROFILE_IMAGE_UPLOAD_PATH = "user-profile-images/";
26+
27+
/**
28+
* 유저의 프로필 이미지를 업데이트한다.
29+
*
30+
* @param user 프로필 이미지를 업데이트할 유저
31+
* @param newProfileImage 새로운 프로필 이미지
32+
*
33+
* @return 새로운 프로필 이미지 URL
34+
*/
35+
public String updateProfileImage(User user, MultipartFile newProfileImage) {
36+
deleteOldProfileImage(user.getId());
37+
userProfileImageRepository.flush();
38+
UserProfileImage newUserProfileImage = uploadNewProfileImage(user, newProfileImage);
39+
return newUserProfileImage.getUrl();
40+
}
41+
42+
/**
43+
* 유저의 프로필 이미지를 삭제한다.
44+
*
45+
* @param userId 프로필 이미지를 삭제할 유저의 id
46+
*/
47+
private void deleteOldProfileImage(Long userId) {
48+
Optional<UserProfileImage> optionalOldProfileImage = userProfileImageRepository.findByUser_Id(userId);
49+
if (optionalOldProfileImage.isPresent()) {
50+
UserProfileImage oldProfileImage = optionalOldProfileImage.get();
51+
userProfileImageRepository.delete(oldProfileImage);
52+
fileService.deleteFile(oldProfileImage.getStoredName());
53+
}
54+
}
55+
56+
/**
57+
* 새로운 프로필 이미지를 업로드한다.
58+
* @param user 프로필 이미지를 업데이트할 유저
59+
* @param newProfileImage 새로운 프로필 이미지
60+
*
61+
* @return 새로운 프로필 이미지 entity
62+
*/
63+
private UserProfileImage uploadNewProfileImage(User user, MultipartFile newProfileImage) {
64+
FileDto uploadedFile = fileService.uploadFile(newProfileImage, USER_PROFILE_IMAGE_UPLOAD_PATH);
65+
UserProfileImage newUserProfileImage = UserProfileImage.create(
66+
user,
67+
uploadedFile.getOriginalName(),
68+
uploadedFile.getStoredName(),
69+
uploadedFile.getUrl());
70+
userProfileImageRepository.save(newUserProfileImage);
71+
return newUserProfileImage;
72+
}
73+
}

src/test/java/com/ajou/hertz/unit/common/file/service/AmazonS3FileServiceTest.java

+14
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,20 @@ void setUpMockProperties() {
123123
verifyEveryMocksShouldHaveNoMoreInteractions();
124124
}
125125

126+
@Test
127+
void S3에_저장된_파일의_이름이_주어지고_파일을_S3_버킷에서_삭제한다() throws Exception {
128+
// given
129+
String storedFileName = "stored-file-name";
130+
willDoNothing().given(s3Client).deleteObject(any(DeleteObjectRequest.class));
131+
132+
// when
133+
sut.deleteFile(storedFileName);
134+
135+
// then
136+
then(s3Client).should().deleteObject(any(DeleteObjectRequest.class));
137+
verifyEveryMocksShouldHaveNoMoreInteractions();
138+
}
139+
126140
private void verifyEveryMocksShouldHaveNoMoreInteractions() {
127141
then(s3Client).shouldHaveNoMoreInteractions();
128142
}

src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerTest.java

+33-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.springframework.context.annotation.Import;
1919
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
2020
import org.springframework.http.MediaType;
21+
import org.springframework.mock.web.MockMultipartFile;
2122
import org.springframework.security.core.userdetails.UserDetails;
2223
import org.springframework.test.context.event.annotation.BeforeTestMethod;
2324
import org.springframework.test.web.servlet.MockMvc;
@@ -212,6 +213,38 @@ public void securitySetUp() throws Exception {
212213
verifyEveryMocksShouldHaveNoMoreInteractions();
213214
}
214215

216+
@Test
217+
void 주어진_id와_변경할_프로필_이미지로_프로필_이미지를_변경한다() throws Exception {
218+
// given
219+
long userId = 1L;
220+
MockMultipartFile profileImage = new MockMultipartFile(
221+
"profileImage",
222+
"test.jpg",
223+
"image/jpeg",
224+
"test".getBytes()
225+
);
226+
UserDetails userDetails = createTestUser(userId);
227+
UserDto expectedResult = createUserDto(userId);
228+
229+
given(userCommandService.updateUserProfileImage(userId, profileImage)).willReturn(expectedResult);
230+
231+
// when & then
232+
mvc.perform(
233+
multipart("/api/users/me/profile-images")
234+
.file(profileImage)
235+
.header(API_VERSION_HEADER_NAME, 1)
236+
.with(user(userDetails))
237+
.with(request -> {
238+
request.setMethod("PUT");
239+
return request;
240+
})
241+
)
242+
.andExpect(status().isOk())
243+
.andExpect(jsonPath("$.profileImageUrl").value(expectedResult.getProfileImageUrl()));
244+
then(userCommandService).should().updateUserProfileImage(userId, profileImage);
245+
verifyEveryMocksShouldHaveNoMoreInteractions();
246+
}
247+
215248
@Test
216249
void 주어진_연락수단을_새로운_연락수단으로_변경한다() throws Exception {
217250
// given
@@ -281,5 +314,4 @@ private UserDto createUserDto() throws Exception {
281314
private UserDetails createTestUser(Long userId) throws Exception {
282315
return new UserPrincipal(createUserDto(userId));
283316
}
284-
285317
}

src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java

+36
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
import org.mockito.InjectMocks;
1818
import org.mockito.Mock;
1919
import org.mockito.junit.jupiter.MockitoExtension;
20+
import org.springframework.mock.web.MockMultipartFile;
2021
import org.springframework.security.crypto.password.PasswordEncoder;
2122
import org.springframework.test.context.event.annotation.BeforeTestMethod;
23+
import org.springframework.web.multipart.MultipartFile;
2224

25+
import com.ajou.hertz.common.file.service.FileService;
2326
import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse;
2427
import com.ajou.hertz.common.properties.HertzProperties;
2528
import com.ajou.hertz.domain.user.constant.Gender;
@@ -33,6 +36,7 @@
3336
import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException;
3437
import com.ajou.hertz.domain.user.repository.UserRepository;
3538
import com.ajou.hertz.domain.user.service.UserCommandService;
39+
import com.ajou.hertz.domain.user.service.UserProfileImageCommandService;
3640
import com.ajou.hertz.domain.user.service.UserQueryService;
3741
import com.ajou.hertz.util.ReflectionUtils;
3842

@@ -55,6 +59,12 @@ class UserCommandServiceTest {
5559
@Mock
5660
private HertzProperties hertzProperties;
5761

62+
@Mock
63+
private FileService fileService;
64+
65+
@Mock
66+
private UserProfileImageCommandService userProfileImageCommandService;
67+
5868
@BeforeTestMethod
5969
public void setUp() {
6070
given(hertzProperties.userDefaultProfileImageUrl()).willReturn("https://user-default-profile-image");
@@ -172,6 +182,30 @@ static Stream<Arguments> testDataForCreateNewUserWithKakao() throws Exception {
172182
assertThat(t).isInstanceOf(UserKakaoUidDuplicationException.class);
173183
}
174184

185+
@Test
186+
void 주어진_유저_ID와_이미지_URL로_유저의_프로필_이미지를_업데이트한다() throws Exception {
187+
// Given
188+
Long userId = 1L;
189+
User user = createUser(userId, "password", "kakaoUid");
190+
String newProfileImageUrl = "https://new-profile-image-url";
191+
192+
MultipartFile profileImage = new MockMultipartFile("file", "test.jpg", "image/jpeg",
193+
"test image content".getBytes());
194+
195+
given(userQueryService.getById(userId)).willReturn(user);
196+
given(userProfileImageCommandService.updateProfileImage(user, profileImage)).willReturn(
197+
newProfileImageUrl);
198+
199+
// When
200+
UserDto result = sut.updateUserProfileImage(userId, profileImage);
201+
202+
// Then
203+
then(userQueryService).should().getById(userId);
204+
then(userProfileImageCommandService).should().updateProfileImage(user, profileImage);
205+
assertThat(result.getProfileImageUrl()).isEqualTo(newProfileImageUrl);
206+
verifyEveryMocksShouldHaveNoMoreInteractions();
207+
}
208+
175209
@Test
176210
void 주어진_유저_ID와_연락_수단으로_연락_수단을_변경한다() throws Exception {
177211
// given
@@ -209,6 +243,8 @@ private void verifyEveryMocksShouldHaveNoMoreInteractions() {
209243
then(userQueryService).shouldHaveNoMoreInteractions();
210244
then(userRepository).shouldHaveNoMoreInteractions();
211245
then(passwordEncoder).shouldHaveNoMoreInteractions();
246+
then(fileService).shouldHaveNoMoreInteractions();
247+
then(userProfileImageCommandService).shouldHaveNoMoreInteractions();
212248
}
213249

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

0 commit comments

Comments
 (0)