From dc380bffb28a27d941b7709511a85fac3376fa83 Mon Sep 17 00:00:00 2001 From: ahdeka Date: Wed, 15 Oct 2025 16:42:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 50 +++++++ .../request/UpdateProfileImageRequest.java | 9 ++ .../back/domain/user/service/UserService.java | 134 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 src/main/java/com/back/domain/user/dto/request/UpdateProfileImageRequest.java diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index e4d7d5a7..7ef88ed4 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -11,9 +11,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Slf4j @RestController @@ -114,4 +116,52 @@ public ResponseEntity> getUserPublicProfile( ); } + /** + * 프로필 이미지 업로드 및 변경 + */ + @PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "프로필 이미지 업로드 및 변경", + description = "프로필 이미지를 S3에 업로드하고 사용자 정보를 자동으로 업데이트합니다. " + + "MAIN 타입으로 업로드되며, 썸네일이 자동 생성됩니다." + ) + public ResponseEntity> uploadProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestPart MultipartFile file) { + + log.info("프로필 이미지 업로드 - userId: {}, filename: {}", + userDetails.getUserId(), file.getOriginalFilename()); + + UserProfileResponse response = userService.uploadAndUpdateProfileImage( + userDetails.getUserId(), + file + ); + + return ResponseEntity.ok( + RsData.of("200", "프로필 이미지 업로드 및 변경 성공", response) + ); + } + + /** + * 프로필 이미지 삭제 + */ + @DeleteMapping("/me/profile-image") + @Operation( + summary = "프로필 이미지 삭제", + description = "현재 설정된 프로필 이미지를 삭제하고 기본 이미지로 되돌립니다. S3에서도 이미지가 삭제됩니다." + ) + public ResponseEntity> deleteProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails) { + + log.info("프로필 이미지 삭제 - userId: {}", userDetails.getUserId()); + + UserProfileResponse response = userService.deleteProfileImage( + userDetails.getUserId() + ); + + return ResponseEntity.ok( + RsData.of("200", "프로필 이미지 삭제 성공", response) + ); + } + } diff --git a/src/main/java/com/back/domain/user/dto/request/UpdateProfileImageRequest.java b/src/main/java/com/back/domain/user/dto/request/UpdateProfileImageRequest.java new file mode 100644 index 00000000..0450e5d1 --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/request/UpdateProfileImageRequest.java @@ -0,0 +1,9 @@ +package com.back.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateProfileImageRequest( + @NotBlank(message = "프로필 이미지 URL은 필수입니다.") + String profileImageUrl +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 7c22396a..15665af7 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -8,11 +8,17 @@ import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ServiceException; +import com.back.global.s3.FileType; +import com.back.global.s3.S3Service; +import com.back.global.s3.UploadResultResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; /** * 사용자 관리 서비스 @@ -25,6 +31,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final S3Service s3Service; /** * 사용자 조회 @@ -291,4 +298,131 @@ public void deleteUser(Long userId) { log.info("계정 삭제: userId={}", userId); } + /** + * 프로필 이미지 업로드 및 변경 + */ + @Transactional + public UserProfileResponse uploadAndUpdateProfileImage(Long userId, MultipartFile file) { + User user = getUserById(userId); + + // 1. 파일 검증 (크기 및 형식) + validateImageFile(file); + + // 2. 기존 프로필 이미지 삭제 (S3에서) + deleteOldProfileImage(user); + + // 3. S3에 새 이미지 업로드 (MAIN 타입 - 썸네일 자동 생성됨) + List uploadResults = s3Service.uploadFiles( + List.of(file), + "profile-images", + List.of(FileType.MAIN) + ); + + if (uploadResults.isEmpty()) { + throw new ServiceException("500", "이미지 업로드에 실패했습니다."); + } + + // 4. MAIN 타입의 이미지 URL 추출 + String profileImageUrl = uploadResults.stream() + .filter(result -> result.type() == FileType.MAIN) + .findFirst() + .map(UploadResultResponse::url) + .orElseThrow(() -> new ServiceException("500", "업로드된 이미지를 찾을 수 없습니다.")); + + // 5. 프로필 이미지 URL 업데이트 + user.updateProfile(null, null, null, null, null, profileImageUrl); + + log.info("프로필 이미지 업로드 및 변경 완료 - userId: {}, imageUrl: {}", + userId, profileImageUrl); + + return UserProfileResponse.from(user); + } + + /** + * 기존 프로필 이미지 삭제 (S3) + */ + private void deleteOldProfileImage(User user) { + if (user.getProfileImageUrl() == null || user.getProfileImageUrl().isBlank()) { + return; + } + + try { + // URL에서 S3 Key 추출하여 삭제 + String oldKey = extractS3KeyFromUrl(user.getProfileImageUrl()); + s3Service.deleteFile(oldKey); + + // 썸네일도 있다면 삭제 + String thumbnailKey = oldKey.replace("profile-images/", "profile-images/thumbnail-"); + try { + s3Service.deleteFile(thumbnailKey); + } catch (Exception e) { + log.debug("썸네일 이미지 없음 또는 삭제 실패 - key: {}", thumbnailKey); + } + + log.info("기존 프로필 이미지 삭제 완료 - key: {}", oldKey); + } catch (Exception e) { + log.warn("기존 프로필 이미지 삭제 실패 - userId: {}", user.getId(), e); + // 삭제 실패해도 진행 (새 이미지는 업로드) + } + } + + /** + * 프로필 이미지 삭제 + */ + @Transactional + public UserProfileResponse deleteProfileImage(Long userId) { + User user = getUserById(userId); + + // 1. 현재 프로필 이미지가 없으면 에러 + if (user.getProfileImageUrl() == null || user.getProfileImageUrl().isBlank()) { + throw new ServiceException("400", "삭제할 프로필 이미지가 없습니다."); + } + + // 2. S3에서 이미지 삭제 + deleteOldProfileImage(user); + + // 3. DB에서 프로필 이미지 URL을 null로 설정 + user.updateProfile(null, null, null, null, null, null); + + log.info("프로필 이미지 삭제 완료 - userId: {}", userId); + + return UserProfileResponse.from(user); + } + + /** + * S3 URL에서 Key 추출 헬퍼 메서드 + */ + private String extractS3KeyFromUrl(String url) { + try { + String[] parts = url.split(".com/"); + if (parts.length > 1) { + return parts[1]; + } + } catch (Exception e) { + log.error("S3 URL 파싱 실패: {}", url, e); + } + throw new ServiceException("400", "잘못된 S3 URL 형식입니다."); + } + + /** + * 파일 크기 검증 + */ + private void validateImageFile(MultipartFile file) { + // 1. 파일이 비어있는지 검증 + if (file == null || file.isEmpty()) { + throw new ServiceException("400", "업로드할 파일이 없습니다."); + } + + // 2. 파일 크기 검증 (5MB 제한) + if (file.getSize() > 5 * 1024 * 1024) { + throw new ServiceException("400", "이미지 크기는 5MB를 초과할 수 없습니다."); + } + + // 3. 이미지 파일 형식 검증 (Content-Type) + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new ServiceException("400", "이미지 파일만 업로드 가능합니다."); + } + } + } From c95fe4c6d980bf260beba115a0914a8664489a22 Mon Sep 17 00:00:00 2001 From: ahdeka Date: Wed, 15 Oct 2025 17:11:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[fix]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/user/entity/User.java | 14 ++++++++++++ .../back/domain/user/service/UserService.java | 22 +++++++++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index e370112f..8717c662 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -298,6 +298,20 @@ public void updateProfile(String name, String phone, String address, } } + /** + * 프로필 이미지 설정 + */ + public void setProfileImage(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + /** + * 프로필 이미지 삭제 (null로 설정) + */ + public void deleteProfileImage() { + this.profileImageUrl = null; + } + /** * 비밀번호 변경 */ diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 15665af7..09193c3f 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -330,7 +330,7 @@ public UserProfileResponse uploadAndUpdateProfileImage(Long userId, MultipartFil .orElseThrow(() -> new ServiceException("500", "업로드된 이미지를 찾을 수 없습니다.")); // 5. 프로필 이미지 URL 업데이트 - user.updateProfile(null, null, null, null, null, profileImageUrl); + user.setProfileImage(profileImageUrl); log.info("프로필 이미지 업로드 및 변경 완료 - userId: {}, imageUrl: {}", userId, profileImageUrl); @@ -382,7 +382,7 @@ public UserProfileResponse deleteProfileImage(Long userId) { deleteOldProfileImage(user); // 3. DB에서 프로필 이미지 URL을 null로 설정 - user.updateProfile(null, null, null, null, null, null); + user.deleteProfileImage(); log.info("프로필 이미지 삭제 완료 - userId: {}", userId); @@ -394,14 +394,22 @@ public UserProfileResponse deleteProfileImage(Long userId) { */ private String extractS3KeyFromUrl(String url) { try { - String[] parts = url.split(".com/"); - if (parts.length > 1) { - return parts[1]; + log.debug("S3 URL 파싱 시도: {}", url); + + // .com/ 이후의 문자열 추출 (리터럴 문자열 사용) + int index = url.indexOf(".com/"); + + if (index != -1 && index + 5 < url.length()) { + String key = url.substring(index + 5); + log.debug("추출된 S3 Key: {}", key); + return key; } + + log.error("URL 파싱 실패: '.com/' 구분자를 찾을 수 없음. URL: {}", url); } catch (Exception e) { - log.error("S3 URL 파싱 실패: {}", url, e); + log.error("S3 URL 파싱 중 예외 발생: {}", url, e); } - throw new ServiceException("400", "잘못된 S3 URL 형식입니다."); + throw new ServiceException("400", "잘못된 S3 URL 형식입니다. URL: " + url); } /**