Skip to content
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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -292,6 +294,25 @@ public ResponseEntity<ApiResponse<NicknameCheckResponseDTO>> 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<ApiResponse<Void>> updateProfileImage(
@AuthenticationPrincipal UserDetails userDetails,
@RequestPart("profileImage") MultipartFile profileImage) {

memberService.updateProfileImage(userDetails.getUsername(), profileImage);
return ApiResponse.success_only(SuccessStatus.UPDATE_PROFILE_IMAGE_SUCCESS);
}

/*
*
* 약관동의 API
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ public void updateNickname(String nickname) {
this.nickname = nickname;
}

/**
* 프로필 이미지 업데이트
*/
public void updateProfileImage(String profileImage) {
this.profileImage = profileImage;
}

/**
* 공개 범위 변경 메서드
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, "파일 삭제에 실패했습니다."),

;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "계정 공개 범위 수정 성공"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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;
}
}