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
11 changes: 6 additions & 5 deletions src/main/java/com/example/paycheck/api/user/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -52,12 +52,13 @@ public ApiResponse<UserDto.Response> updateMyInfo(
return ApiResponse.success(userService.updateUser(user.getId(), request));
}

@Operation(summary = "내 프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 S3에 업로드하고 URL을 반환합니다.")
@Operation(summary = "내 프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드합니다.")
@PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<UserDto.ProfileImageUploadResponse> uploadMyProfileImage(
public ApiResponse<UserDto.Response> uploadMyProfileImage(
@AuthenticationPrincipal User user,
@Parameter(description = "프로필 이미지 파일", required = true) @RequestPart("file") MultipartFile file) {
return ApiResponse.success(userService.uploadProfileImage(user.getId(), file));
@Parameter(description = "프로필 이미지 파일", required = true)
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(userService.updateProfileImage(user.getId(), file));
}

@Operation(summary = "계좌 정보 수정 (근로자 전용)", description = "로그인한 근로자의 계좌 정보를 수정합니다.")
Expand Down
14 changes: 11 additions & 3 deletions src/main/java/com/example/paycheck/domain/user/dto/UserDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,32 @@ public static class Response {
private String accountNumber;

public static Response from(User user) {
return from(user, user.getProfileImageUrl());
}

public static Response from(User user, String profileImageUrl) {
return Response.builder()
.id(user.getId())
.kakaoId(user.getKakaoId())
.name(user.getName())
.phone(user.getPhone())
.userType(user.getUserType())
.profileImageUrl(user.getProfileImageUrl())
.profileImageUrl(profileImageUrl)
.build();
}

public static Response from(User user, Worker worker) {
return from(user, worker, user.getProfileImageUrl());
}

public static Response from(User user, Worker worker, String profileImageUrl) {
return Response.builder()
.id(user.getId())
.kakaoId(user.getKakaoId())
.name(user.getName())
.phone(user.getPhone())
.userType(user.getUserType())
.profileImageUrl(user.getProfileImageUrl())
.profileImageUrl(profileImageUrl)
.workerCode(worker.getWorkerCode())
.bankName(worker.getBankName())
.accountNumber(worker.getAccountNumber())
Expand All @@ -69,7 +77,7 @@ public static class UpdateRequest {
@Pattern(regexp = "^01[0-9]-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다. (예: 010-1234-5678)")
private String phone;

@Pattern(regexp = "^(https?://.+)?$", message = "프로필 이미지는 HTTP/HTTPS URL 형식이어야 합니다.")
@Pattern(regexp = "^(https?://.+|/.+)?$", message = "프로필 이미지는 HTTP/HTTPS URL 또는 서버 이미지 경로 형식이어야 합니다.")
private String profileImageUrl;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,137 +2,111 @@

import com.example.paycheck.common.exception.BadRequestException;
import com.example.paycheck.common.exception.ErrorCode;
import com.example.paycheck.common.exception.FileUploadException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.model.GetUrlRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.S3Client;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;

@Service
public class ProfileImageStorageService {

private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif"
);
private static final String DEFAULT_PROFILE_IMAGE_DIR = "profiles";

private final S3Client s3Client;
private final String bucket;
private final String profileImageDir;
private static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "webp");

private final Path profileImageDirectory;
private final String publicUriPrefix;

public ProfileImageStorageService(
S3Client s3Client,
@Value("${aws.s3.bucket:}") String bucket,
@Value("${aws.s3.profile-image-dir:profiles}") String profileImageDir) {
this.s3Client = s3Client;
this.bucket = bucket;
this.profileImageDir = normalizeProfileImageDir(profileImageDir);
@Value("${app.upload.profile-image-dir:uploads/profile-images}") String profileImageDirectory,
@Value("${app.upload.profile-image-uri-prefix:/uploads/profile-images/}") String publicUriPrefix) {
this.profileImageDirectory = Paths.get(profileImageDirectory).toAbsolutePath().normalize();
this.publicUriPrefix = normalizePublicUriPrefix(publicUriPrefix);
}

public String uploadProfileImage(Long userId, MultipartFile file) {
validateFile(file);
validateBucketConfiguration();
public String store(MultipartFile file) {
validate(file);

String contentType = file.getContentType();
String objectKey = buildObjectKey(userId, file);

try (InputStream inputStream = file.getInputStream()) {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.contentType(contentType)
.build();

s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize()));

URL uploadedFileUrl = s3Client.utilities()
.getUrl(GetUrlRequest.builder().bucket(bucket).key(objectKey).build());

return uploadedFileUrl.toExternalForm();
} catch (IOException | SdkException e) {
throw new FileUploadException(
ErrorCode.PROFILE_IMAGE_UPLOAD_FAILED,
"프로필 이미지 업로드에 실패했습니다.",
e
);
try {
Files.createDirectories(profileImageDirectory);

String extension = extractExtension(file.getOriginalFilename());
String storedFileName = UUID.randomUUID() + "." + extension;
Path targetPath = profileImageDirectory.resolve(storedFileName).normalize();

file.transferTo(targetPath);
return publicUriPrefix + storedFileName;
} catch (IOException e) {
throw new IllegalStateException("프로필 이미지를 저장할 수 없습니다.", e);
}
}

private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BadRequestException(ErrorCode.INVALID_PROFILE_IMAGE_FILE, "업로드할 이미지 파일이 비어 있습니다.");
public void deleteIfStoredLocally(String profileImageUrl) {
if (!StringUtils.hasText(profileImageUrl) || !profileImageUrl.startsWith(publicUriPrefix)) {
return;
}

String contentType = file.getContentType();
if (!StringUtils.hasText(contentType) || !ALLOWED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) {
throw new BadRequestException(
ErrorCode.INVALID_PROFILE_IMAGE_FILE,
"프로필 이미지는 JPG, PNG, GIF, WEBP, HEIC 형식만 업로드할 수 있습니다."
);
String fileName = profileImageUrl.substring(publicUriPrefix.length());
if (!StringUtils.hasText(fileName)) {
return;
}
}

private void validateBucketConfiguration() {
if (!StringUtils.hasText(bucket)) {
throw new FileUploadException(
ErrorCode.PROFILE_IMAGE_UPLOAD_NOT_CONFIGURED,
"프로필 이미지 업로드를 위한 S3 버킷 설정이 필요합니다."
);
try {
Files.deleteIfExists(profileImageDirectory.resolve(fileName).normalize());
} catch (IOException ignored) {
// 이전 프로필 이미지 정리에 실패해도 업로드 성공 흐름은 유지한다.
}
}

private String buildObjectKey(Long userId, MultipartFile file) {
String extension = resolveExtension(file);
String fileName = UUID.randomUUID() + (StringUtils.hasText(extension) ? "." + extension : "");
return profileImageDir + "/" + userId + "/" + fileName;
public Path getProfileImageDirectory() {
return profileImageDirectory;
}

private String resolveExtension(MultipartFile file) {
String originalExtension = StringUtils.getFilenameExtension(file.getOriginalFilename());
if (StringUtils.hasText(originalExtension)) {
return sanitizeExtension(originalExtension);
public String getPublicUriPrefix() {
return publicUriPrefix;
}

private void validate(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "업로드할 프로필 이미지 파일이 필요합니다.");
}

return switch (file.getContentType().toLowerCase(Locale.ROOT)) {
case "image/jpeg", "image/jpg" -> "jpg";
case "image/png" -> "png";
case "image/gif" -> "gif";
case "image/webp" -> "webp";
case "image/heic" -> "heic";
case "image/heif" -> "heif";
default -> "";
};
String contentType = file.getContentType();
if (!StringUtils.hasText(contentType) || !contentType.startsWith("image/")) {
throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "이미지 파일만 업로드할 수 있습니다.");
}

extractExtension(file.getOriginalFilename());
}

private String sanitizeExtension(String extension) {
private String extractExtension(String originalFilename) {
String extension = StringUtils.getFilenameExtension(originalFilename);
if (!StringUtils.hasText(extension)) {
throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "지원하지 않는 이미지 형식입니다.");
}

String normalizedExtension = extension.toLowerCase(Locale.ROOT);
return normalizedExtension.matches("[a-z0-9]+") ? normalizedExtension : "";
if (!ALLOWED_EXTENSIONS.contains(normalizedExtension)) {
throw new BadRequestException(ErrorCode.INVALID_INPUT_VALUE, "지원하지 않는 이미지 형식입니다.");
}

return normalizedExtension;
}

private String normalizeProfileImageDir(String profileImageDir) {
if (!StringUtils.hasText(profileImageDir)) {
return DEFAULT_PROFILE_IMAGE_DIR;
private String normalizePublicUriPrefix(String publicUriPrefix) {
String normalized = StringUtils.hasText(publicUriPrefix) ? publicUriPrefix.trim() : "/uploads/profile-images/";
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}

String normalizedProfileImageDir = profileImageDir.replaceAll("^/+", "").replaceAll("/+$", "");
return StringUtils.hasText(normalizedProfileImageDir) ? normalizedProfileImageDir : DEFAULT_PROFILE_IMAGE_DIR;
if (!normalized.endsWith("/")) {
normalized = normalized + "/";
}
return normalized;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example.paycheck.domain.user.service;

import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@Service
public class ProfileImageUrlResolver {

public String resolve(String profileImageUrl) {
if (!StringUtils.hasText(profileImageUrl)) {
return profileImageUrl;
}

if (profileImageUrl.startsWith("http://") || profileImageUrl.startsWith("https://")) {
return profileImageUrl;
}

if (!profileImageUrl.startsWith("/")) {
return profileImageUrl;
}

try {
return ServletUriComponentsBuilder.fromCurrentContextPath()
.path(profileImageUrl)
.toUriString();
} catch (IllegalStateException e) {
return profileImageUrl;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@ public class UserService {
private final EmployerService employerService;
private final UserSettingsService userSettingsService;
private final ProfileImageStorageService profileImageStorageService;
private final ProfileImageUrlResolver profileImageUrlResolver;

public UserDto.Response getUserById(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));

if (user.getUserType() == UserType.WORKER) {
return workerRepository.findByUserId(userId)
.map(worker -> UserDto.Response.from(user, worker))
.orElse(UserDto.Response.from(user));
.map(worker -> buildUserResponse(user, worker))
.orElse(buildUserResponse(user));
}
return UserDto.Response.from(user);
return buildUserResponse(user);
}

@Transactional
Expand All @@ -47,17 +48,23 @@ public UserDto.Response updateUser(Long userId, UserDto.UpdateRequest request) {

user.updateProfile(request.getName(), request.getPhone(), request.getProfileImageUrl());

return UserDto.Response.from(user);
return getUserById(userId);
}

@Transactional
public UserDto.ProfileImageUploadResponse uploadProfileImage(Long userId, MultipartFile file) {
public UserDto.Response updateProfileImage(Long userId, MultipartFile profileImage) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다."));

String profileImageUrl = profileImageStorageService.uploadProfileImage(userId, file);
user.updateProfileImage(profileImageUrl);
return UserDto.ProfileImageUploadResponse.from(profileImageUrl);
String previousProfileImageUrl = user.getProfileImageUrl();
String storedProfileImageUrl = profileImageStorageService.store(profileImage);
user.updateProfile(null, null, storedProfileImageUrl);

if (previousProfileImageUrl != null && !previousProfileImageUrl.equals(storedProfileImageUrl)) {
profileImageStorageService.deleteIfStoredLocally(previousProfileImageUrl);
}

return getUserById(userId);
}

@Transactional
Expand Down Expand Up @@ -88,4 +95,12 @@ public UserDto.RegisterResponse register(UserDto.RegisterRequest request) {

return UserDto.RegisterResponse.from(savedUser, workerCode);
}

private UserDto.Response buildUserResponse(User user) {
return UserDto.Response.from(user, profileImageUrlResolver.resolve(user.getProfileImageUrl()));
}

private UserDto.Response buildUserResponse(User user, Worker worker) {
return UserDto.Response.from(user, worker, profileImageUrlResolver.resolve(user.getProfileImageUrl()));
}
}
Loading
Loading