diff --git a/build.gradle b/build.gradle index 04255d2..834f997 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,9 @@ dependencies { // MariaDB runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + // Minio (S3 compatible object storage) + implementation 'io.minio:minio:8.6.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java index 669d07a..12fdfff 100644 --- a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -12,10 +12,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -292,6 +294,25 @@ public ResponseEntity> checkNicknameDuplic return ApiResponse.success(SuccessStatus.CHECK_NICKNAME_DUPLICATE_SUCCESS, nicknameCheckResponseDTO); } + @Operation( + summary = "프로필 이미지 변경 API", + description = "사용자의 프로필 이미지를 변경합니다. 기존 이미지가 있으면 삭제 후 새 이미지를 저장합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "프로필 이미지 변경 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "업로드할 이미지가 없거나 이미지 형식이 아닙니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 사용자를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "파일 업로드/삭제에 실패했습니다.") + }) + @PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> updateProfileImage( + @AuthenticationPrincipal UserDetails userDetails, + @RequestPart("profileImage") MultipartFile profileImage) { + + memberService.updateProfileImage(userDetails.getUsername(), profileImage); + return ApiResponse.success_only(SuccessStatus.UPDATE_PROFILE_IMAGE_SUCCESS); + } + /* * * 약관동의 API diff --git a/src/main/java/com/moongeul/backend/api/member/entity/Member.java b/src/main/java/com/moongeul/backend/api/member/entity/Member.java index 0455a38..649697c 100644 --- a/src/main/java/com/moongeul/backend/api/member/entity/Member.java +++ b/src/main/java/com/moongeul/backend/api/member/entity/Member.java @@ -80,6 +80,13 @@ public void updateNickname(String nickname) { this.nickname = nickname; } + /** + * 프로필 이미지 업데이트 + */ + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } + /** * 공개 범위 변경 메서드 */ diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java index d130969..84ada27 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java @@ -23,13 +23,16 @@ import com.moongeul.backend.common.exception.NotFoundException; import com.moongeul.backend.common.exception.UnauthorizedException; import com.moongeul.backend.common.response.ErrorStatus; +import com.moongeul.backend.common.service.FileUploadService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.util.StringUtils; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import java.util.*; import java.util.stream.Collectors; @@ -50,6 +53,7 @@ public class MemberService { private final NicknameGenerator nicknameGenerator; private final TermsRepository termsRepository; private final AgreeRepository agreeRepository; + private final FileUploadService fileUploadService; // 인가코드 받아 JWT로 교환 및 회원가입/로그인 처리 @Transactional @@ -291,6 +295,20 @@ public NicknameCheckResponseDTO checkNicknameDuplicate(String nickname) { .build(); } + // 프로필 이미지 변경 + @Transactional + public void updateProfileImage(String email, MultipartFile profileImage) { + validateProfileImage(profileImage); + + Member member = getMemberByEmail(email); + String previousProfileImage = member.getProfileImage(); + + String uploadedProfileImageUrl = fileUploadService.uploadFile(profileImage, "profile/" + member.getId()); + member.updateProfileImage(uploadedProfileImageUrl); + + fileUploadService.deleteFileByUrl(previousProfileImage); + } + // 카테고리별 기록 리스트 조회 @Transactional(readOnly = true) public CategoryPostListResponseDTO getCategoryPostList(String email, Long userId, Long categoryId, String sortBy, Integer page, Integer size) { @@ -473,4 +491,15 @@ private Member getMemberByEmail(String email) { return memberRepository.findByEmail(email) .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); } + + private void validateProfileImage(MultipartFile profileImage) { + if (profileImage == null || profileImage.isEmpty()) { + throw new BadRequestException(ErrorStatus.PROFILE_IMAGE_EMPTY_EXCEPTION.getMessage()); + } + + String contentType = profileImage.getContentType(); + if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) { + throw new BadRequestException(ErrorStatus.PROFILE_IMAGE_INVALID_TYPE_EXCEPTION.getMessage()); + } + } } diff --git a/src/main/java/com/moongeul/backend/common/config/minio/MinioConfig.java b/src/main/java/com/moongeul/backend/common/config/minio/MinioConfig.java new file mode 100644 index 0000000..580c316 --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/config/minio/MinioConfig.java @@ -0,0 +1,20 @@ +package com.moongeul.backend.common.config.minio; + +import io.minio.MinioClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(MinioProperties.class) +public class MinioConfig { + + @Bean + public MinioClient minioClient(MinioProperties minioProperties) { + return MinioClient.builder() + .endpoint(minioProperties.getEndpoint()) + .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey()) + .region(minioProperties.getRegion()) + .build(); + } +} diff --git a/src/main/java/com/moongeul/backend/common/config/minio/MinioProperties.java b/src/main/java/com/moongeul/backend/common/config/minio/MinioProperties.java new file mode 100644 index 0000000..18293b2 --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/config/minio/MinioProperties.java @@ -0,0 +1,18 @@ +package com.moongeul.backend.common.config.minio; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Builder +@ConfigurationProperties(prefix = "minio") +public class MinioProperties { + + private String endpoint; + private String publicEndpoint; + private String accessKey; + private String secretKey; + private String bucket; + private String region; +} diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index 316b9d3..ba383f7 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -26,6 +26,8 @@ public enum ErrorStatus { NO_SCORE_RESULT(HttpStatus.BAD_REQUEST, "테스트 점수 계산 결과가 없습니다."), REVIEW_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "수정하려는 회원의 리뷰가 아닙니다."), DISAGREE_REQUIRED_TERM(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."), + PROFILE_IMAGE_EMPTY_EXCEPTION(HttpStatus.BAD_REQUEST, "업로드할 프로필 이미지가 없습니다."), + PROFILE_IMAGE_INVALID_TYPE_EXCEPTION(HttpStatus.BAD_REQUEST, "이미지 파일만 업로드할 수 있습니다."), /** * 401 UNAUTHORIZED @@ -69,6 +71,8 @@ public enum ErrorStatus { INTERNAL_SERVER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"서버 내부 오류 발생"), SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "로그인 서버 오류 발생"), NAVER_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "네이버 서버 오류 발생"), + FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + FILE_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."), ; diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 88ea67e..53d371b 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -26,6 +26,7 @@ public enum SuccessStatus { GET_FOLLOWER_SUCCESS(HttpStatus.OK, "팔로워 목록 조회 성공"), REGENERATE_NICKNAME_SUCCESS(HttpStatus.OK, "닉네임 재생성 성공"), UPDATE_NICKNAME_SUCCESS(HttpStatus.OK, "닉네임 등록 성공"), + UPDATE_PROFILE_IMAGE_SUCCESS(HttpStatus.OK, "프로필 이미지 변경 성공"), CHECK_NICKNAME_DUPLICATE_SUCCESS(HttpStatus.OK, "닉네임 중복 체크 성공"), GET_PRIVACY_LEVEL_SUCCESS(HttpStatus.OK, "계정 공개 범위 조회 성공"), UPDATE_PRIVACY_LEVEL_SUCCESS(HttpStatus.OK, "계정 공개 범위 수정 성공"), diff --git a/src/main/java/com/moongeul/backend/common/service/FileUploadService.java b/src/main/java/com/moongeul/backend/common/service/FileUploadService.java new file mode 100644 index 0000000..33c5bfa --- /dev/null +++ b/src/main/java/com/moongeul/backend/common/service/FileUploadService.java @@ -0,0 +1,173 @@ +package com.moongeul.backend.common.service; + +import com.moongeul.backend.common.config.minio.MinioProperties; +import com.moongeul.backend.common.exception.InternalServerException; +import com.moongeul.backend.common.response.ErrorStatus; +import io.minio.BucketExistsArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.net.URI; +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FileUploadService { + + private final MinioClient minioClient; + private final MinioProperties minioProperties; + + public String uploadFile(MultipartFile file, String objectPrefix) { + ensureBucketExists(); + + String extension = resolveExtension(file.getOriginalFilename(), file.getContentType()); + String objectName = createObjectName(objectPrefix, extension); + String contentType = StringUtils.hasText(file.getContentType()) ? file.getContentType() : "application/octet-stream"; + + try (InputStream inputStream = file.getInputStream()) { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(minioProperties.getBucket()) + .object(objectName) + .stream(inputStream, file.getSize(), -1) + .contentType(contentType) + .build() + ); + } catch (Exception e) { + log.error("파일 업로드 실패 - objectName: {}", objectName, e); + throw new InternalServerException(ErrorStatus.FILE_UPLOAD_FAIL.getMessage()); + } + + return buildPublicUrl(objectName); + } + + public void deleteFileByUrl(String fileUrl) { + Optional objectName = extractObjectName(fileUrl); + if (objectName.isEmpty()) { + return; + } + + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(minioProperties.getBucket()) + .object(objectName.get()) + .build() + ); + } catch (Exception e) { + log.error("파일 삭제 실패 - objectName: {}", objectName.get(), e); + throw new InternalServerException(ErrorStatus.FILE_DELETE_FAIL.getMessage()); + } + } + + private void ensureBucketExists() { + try { + boolean exists = minioClient.bucketExists( + BucketExistsArgs.builder() + .bucket(minioProperties.getBucket()) + .build() + ); + + if (!exists) { + minioClient.makeBucket( + MakeBucketArgs.builder() + .bucket(minioProperties.getBucket()) + .build() + ); + } + } catch (Exception e) { + log.error("Minio 버킷 확인/생성 실패 - bucket: {}", minioProperties.getBucket(), e); + throw new InternalServerException(ErrorStatus.INTERNAL_SERVER_EXCEPTION.getMessage()); + } + } + + private String createObjectName(String objectPrefix, String extension) { + LocalDate today = LocalDate.now(); + String prefix = normalizePrefix(objectPrefix); + + return String.format( + "%s/%d/%02d/%s.%s", + prefix, + today.getYear(), + today.getMonthValue(), + UUID.randomUUID(), + extension + ); + } + + private String normalizePrefix(String objectPrefix) { + if (!StringUtils.hasText(objectPrefix)) { + return "common"; + } + + String trimmed = objectPrefix.trim(); + while (trimmed.startsWith("/")) { + trimmed = trimmed.substring(1); + } + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + + return StringUtils.hasText(trimmed) ? trimmed : "common"; + } + + private String resolveExtension(String originalFilename, String contentType) { + String extension = StringUtils.getFilenameExtension(originalFilename); + if (StringUtils.hasText(extension)) { + return extension.toLowerCase(); + } + + if (!StringUtils.hasText(contentType)) { + return "bin"; + } + + if (contentType.contains("/")) { + return contentType.substring(contentType.indexOf('/') + 1).toLowerCase(); + } + + return "bin"; + } + + private String buildPublicUrl(String objectName) { + String endpoint = trimTrailingSlash(minioProperties.getPublicEndpoint()); + return endpoint + "/" + minioProperties.getBucket() + "/" + objectName; + } + + private Optional extractObjectName(String fileUrl) { + if (!StringUtils.hasText(fileUrl)) { + return Optional.empty(); + } + + try { + URI uri = URI.create(fileUrl); + String path = uri.getPath(); + String prefix = "/" + minioProperties.getBucket() + "/"; + + if (!StringUtils.hasText(path) || !path.startsWith(prefix)) { + return Optional.empty(); + } + + return Optional.of(path.substring(prefix.length())); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private String trimTrailingSlash(String endpoint) { + if (!StringUtils.hasText(endpoint)) { + return ""; + } + return endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + } +}