88import com .back .domain .user .entity .User ;
99import com .back .domain .user .repository .UserRepository ;
1010import com .back .global .exception .ServiceException ;
11+ import com .back .global .s3 .FileType ;
12+ import com .back .global .s3 .S3Service ;
13+ import com .back .global .s3 .UploadResultResponse ;
1114import lombok .RequiredArgsConstructor ;
1215import lombok .extern .slf4j .Slf4j ;
1316import org .springframework .security .crypto .password .PasswordEncoder ;
1417import org .springframework .stereotype .Service ;
1518import org .springframework .transaction .annotation .Transactional ;
19+ import org .springframework .web .multipart .MultipartFile ;
20+
21+ import java .util .List ;
1622
1723/**
1824 * 사용자 관리 서비스
@@ -25,6 +31,7 @@ public class UserService {
2531
2632 private final UserRepository userRepository ;
2733 private final PasswordEncoder passwordEncoder ;
34+ private final S3Service s3Service ;
2835
2936 /**
3037 * 사용자 조회
@@ -291,4 +298,139 @@ public void deleteUser(Long userId) {
291298 log .info ("계정 삭제: userId={}" , userId );
292299 }
293300
301+ /**
302+ * 프로필 이미지 업로드 및 변경
303+ */
304+ @ Transactional
305+ public UserProfileResponse uploadAndUpdateProfileImage (Long userId , MultipartFile file ) {
306+ User user = getUserById (userId );
307+
308+ // 1. 파일 검증 (크기 및 형식)
309+ validateImageFile (file );
310+
311+ // 2. 기존 프로필 이미지 삭제 (S3에서)
312+ deleteOldProfileImage (user );
313+
314+ // 3. S3에 새 이미지 업로드 (MAIN 타입 - 썸네일 자동 생성됨)
315+ List <UploadResultResponse > uploadResults = s3Service .uploadFiles (
316+ List .of (file ),
317+ "profile-images" ,
318+ List .of (FileType .MAIN )
319+ );
320+
321+ if (uploadResults .isEmpty ()) {
322+ throw new ServiceException ("500" , "이미지 업로드에 실패했습니다." );
323+ }
324+
325+ // 4. MAIN 타입의 이미지 URL 추출
326+ String profileImageUrl = uploadResults .stream ()
327+ .filter (result -> result .type () == FileType .MAIN )
328+ .findFirst ()
329+ .map (UploadResultResponse ::url )
330+ .orElseThrow (() -> new ServiceException ("500" , "업로드된 이미지를 찾을 수 없습니다." ));
331+
332+ // 5. 프로필 이미지 URL 업데이트
333+ user .setProfileImage (profileImageUrl );
334+
335+ log .info ("프로필 이미지 업로드 및 변경 완료 - userId: {}, imageUrl: {}" ,
336+ userId , profileImageUrl );
337+
338+ return UserProfileResponse .from (user );
339+ }
340+
341+ /**
342+ * 기존 프로필 이미지 삭제 (S3)
343+ */
344+ private void deleteOldProfileImage (User user ) {
345+ if (user .getProfileImageUrl () == null || user .getProfileImageUrl ().isBlank ()) {
346+ return ;
347+ }
348+
349+ try {
350+ // URL에서 S3 Key 추출하여 삭제
351+ String oldKey = extractS3KeyFromUrl (user .getProfileImageUrl ());
352+ s3Service .deleteFile (oldKey );
353+
354+ // 썸네일도 있다면 삭제
355+ String thumbnailKey = oldKey .replace ("profile-images/" , "profile-images/thumbnail-" );
356+ try {
357+ s3Service .deleteFile (thumbnailKey );
358+ } catch (Exception e ) {
359+ log .debug ("썸네일 이미지 없음 또는 삭제 실패 - key: {}" , thumbnailKey );
360+ }
361+
362+ log .info ("기존 프로필 이미지 삭제 완료 - key: {}" , oldKey );
363+ } catch (Exception e ) {
364+ log .warn ("기존 프로필 이미지 삭제 실패 - userId: {}" , user .getId (), e );
365+ // 삭제 실패해도 진행 (새 이미지는 업로드)
366+ }
367+ }
368+
369+ /**
370+ * 프로필 이미지 삭제
371+ */
372+ @ Transactional
373+ public UserProfileResponse deleteProfileImage (Long userId ) {
374+ User user = getUserById (userId );
375+
376+ // 1. 현재 프로필 이미지가 없으면 에러
377+ if (user .getProfileImageUrl () == null || user .getProfileImageUrl ().isBlank ()) {
378+ throw new ServiceException ("400" , "삭제할 프로필 이미지가 없습니다." );
379+ }
380+
381+ // 2. S3에서 이미지 삭제
382+ deleteOldProfileImage (user );
383+
384+ // 3. DB에서 프로필 이미지 URL을 null로 설정
385+ user .deleteProfileImage ();
386+
387+ log .info ("프로필 이미지 삭제 완료 - userId: {}" , userId );
388+
389+ return UserProfileResponse .from (user );
390+ }
391+
392+ /**
393+ * S3 URL에서 Key 추출 헬퍼 메서드
394+ */
395+ private String extractS3KeyFromUrl (String url ) {
396+ try {
397+ log .debug ("S3 URL 파싱 시도: {}" , url );
398+
399+ // .com/ 이후의 문자열 추출 (리터럴 문자열 사용)
400+ int index = url .indexOf (".com/" );
401+
402+ if (index != -1 && index + 5 < url .length ()) {
403+ String key = url .substring (index + 5 );
404+ log .debug ("추출된 S3 Key: {}" , key );
405+ return key ;
406+ }
407+
408+ log .error ("URL 파싱 실패: '.com/' 구분자를 찾을 수 없음. URL: {}" , url );
409+ } catch (Exception e ) {
410+ log .error ("S3 URL 파싱 중 예외 발생: {}" , url , e );
411+ }
412+ throw new ServiceException ("400" , "잘못된 S3 URL 형식입니다. URL: " + url );
413+ }
414+
415+ /**
416+ * 파일 크기 검증
417+ */
418+ private void validateImageFile (MultipartFile file ) {
419+ // 1. 파일이 비어있는지 검증
420+ if (file == null || file .isEmpty ()) {
421+ throw new ServiceException ("400" , "업로드할 파일이 없습니다." );
422+ }
423+
424+ // 2. 파일 크기 검증 (5MB 제한)
425+ if (file .getSize () > 5 * 1024 * 1024 ) {
426+ throw new ServiceException ("400" , "이미지 크기는 5MB를 초과할 수 없습니다." );
427+ }
428+
429+ // 3. 이미지 파일 형식 검증 (Content-Type)
430+ String contentType = file .getContentType ();
431+ if (contentType == null || !contentType .startsWith ("image/" )) {
432+ throw new ServiceException ("400" , "이미지 파일만 업로드 가능합니다." );
433+ }
434+ }
435+
294436}
0 commit comments