diff --git a/src/main/java/org/runimo/runimo/auth/controller/AuthController.java b/src/main/java/org/runimo/runimo/auth/controller/AuthController.java index 0927bbfc..ab6faf8d 100644 --- a/src/main/java/org/runimo/runimo/auth/controller/AuthController.java +++ b/src/main/java/org/runimo/runimo/auth/controller/AuthController.java @@ -25,12 +25,16 @@ import org.runimo.runimo.common.response.SuccessResponse; import org.runimo.runimo.exceptions.RegisterErrorResponse; import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Auth API", description = "인증 관련 API 모음") @RestController @@ -86,11 +90,13 @@ public ResponseEntity appleLogin( schema = @Schema(implementation = ErrorResponse.class))), @ApiResponse(responseCode = "409", description = "이미 존재하는 사용자") }) - @PostMapping("/signup") + @PostMapping(value = "/signup", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity> signupAndLogin( - @Valid @RequestBody AuthSignupRequest request) { + @RequestParam @Valid AuthSignupRequest request, + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage + ) { SignupUserResponse authResult = signUpUsecase.register( - request.toUserSignupCommand() + request.toUserSignupCommand(profileImage) ); return ResponseEntity.created(URI.create("/api/v1/user" + authResult.userId())) .body(SuccessResponse.of( diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupConverter.java b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupConverter.java new file mode 100644 index 00000000..836c1378 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupConverter.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.auth.controller.request; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthSignupConverter implements Converter { + + private final ObjectMapper objectMapper; + @SneakyThrows + @Override + public AuthSignupRequest convert(@NotNull String source) { + return objectMapper.readValue(source, AuthSignupRequest.class); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java index 87c63e14..50f3dc04 100644 --- a/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java +++ b/src/main/java/org/runimo/runimo/auth/controller/request/AuthSignupRequest.java @@ -2,9 +2,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; import org.runimo.runimo.auth.service.dto.UserSignupCommand; import org.runimo.runimo.user.domain.Gender; +import org.springframework.web.multipart.MultipartFile; @Schema(description = "사용자 회원가입 요청 DTO") public record AuthSignupRequest( @@ -14,14 +14,11 @@ public record AuthSignupRequest( @Schema(description = "사용자 닉네임", example = "RunimoUser") @NotBlank String nickname, - @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.jpg") - @URL String imgUrl, - @Schema(description = "성별", example = "FEMALE") Gender gender ) { - public UserSignupCommand toUserSignupCommand() { - return new UserSignupCommand(registerToken, nickname, imgUrl, gender); + public UserSignupCommand toUserSignupCommand(MultipartFile file) { + return new UserSignupCommand(registerToken, nickname, file, gender); } } diff --git a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java index ee9f2732..d066eaef 100644 --- a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java @@ -9,6 +9,7 @@ import org.runimo.runimo.auth.repository.SignupTokenRepository; import org.runimo.runimo.auth.service.dto.SignupUserResponse; import org.runimo.runimo.auth.service.dto.UserSignupCommand; +import org.runimo.runimo.external.FileStorageService; import org.runimo.runimo.user.domain.AppleUserToken; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; @@ -24,6 +25,7 @@ public class SignUpUsecaseImpl implements SignUpUsecase { private static final int REGISTER_CUTOFF_MIN = 10; private final UserRegisterService userRegisterService; + private final FileStorageService fileStorageService; private final JwtTokenFactory jwtTokenFactory; private final SignupTokenRepository signupTokenRepository; private final AppleUserTokenRepository appleUserTokenRepository; @@ -34,9 +36,10 @@ public class SignUpUsecaseImpl implements SignUpUsecase { public SignupUserResponse register(UserSignupCommand command) { SignupTokenPayload payload = jwtResolver.getSignupTokenPayload(command.registerToken()); SignupToken signupToken = findUnExpiredSignupToken(payload.token()); + String imgUrl = fileStorageService.storeFile(command.profileImage()); User savedUser = userRegisterService.registerUser(new UserRegisterCommand( command.nickname(), - command.imgUrl(), + imgUrl, command.gender(), payload.providerId(), payload.socialProvider()) diff --git a/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java b/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java index d22f0ab6..9eefe623 100644 --- a/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java +++ b/src/main/java/org/runimo/runimo/auth/service/dto/UserSignupCommand.java @@ -2,11 +2,12 @@ import org.runimo.runimo.user.domain.Gender; +import org.springframework.web.multipart.MultipartFile; public record UserSignupCommand( String registerToken, String nickname, - String imgUrl, + MultipartFile profileImage, Gender gender ) { diff --git a/src/main/java/org/runimo/runimo/config/S3Config.java b/src/main/java/org/runimo/runimo/config/S3Config.java index 397596b4..2ab5a9f7 100644 --- a/src/main/java/org/runimo/runimo/config/S3Config.java +++ b/src/main/java/org/runimo/runimo/config/S3Config.java @@ -4,7 +4,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration @@ -28,4 +31,14 @@ public S3Presigner amazonS3Client() { ) .build(); } + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .build(); + } } diff --git a/src/main/java/org/runimo/runimo/config/WebMvcConfig.java b/src/main/java/org/runimo/runimo/config/WebMvcConfig.java index db1a5125..b42984e8 100644 --- a/src/main/java/org/runimo/runimo/config/WebMvcConfig.java +++ b/src/main/java/org/runimo/runimo/config/WebMvcConfig.java @@ -2,8 +2,10 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.controller.request.AuthSignupConverter; import org.runimo.runimo.user.controller.UserIdResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -12,6 +14,12 @@ public class WebMvcConfig implements WebMvcConfigurer { private final UserIdResolver userIdResolver; + private final AuthSignupConverter authSignupConverter; + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(authSignupConverter); + } @Override public void addArgumentResolvers(List resolvers) { diff --git a/src/main/java/org/runimo/runimo/external/ExternalResponseCode.java b/src/main/java/org/runimo/runimo/external/ExternalResponseCode.java index 32b86173..0a3c5ff5 100644 --- a/src/main/java/org/runimo/runimo/external/ExternalResponseCode.java +++ b/src/main/java/org/runimo/runimo/external/ExternalResponseCode.java @@ -6,7 +6,9 @@ public enum ExternalResponseCode implements CustomResponseCode { PRESIGNED_URL_FETCHED("ESH2011", HttpStatus.CREATED, "Presigned URL 발급 성공", "Presigned URL 발급 성공"), - PRESIGNED_URL_FETCH_FAILED("ESH4001", HttpStatus.BAD_REQUEST, "Presigned URL 발급 실패", "Presigned URL 발급 실패"); + PRESIGNED_URL_FETCH_FAILED("ESH4001", HttpStatus.BAD_REQUEST, "Presigned URL 발급 실패", "Presigned URL 발급 실패"), + FILE_UPLOAD_FAILED("ESH4002", HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드 실패", "파일 업로드 실패"),; + private final String code; private final HttpStatus httpStatus; private final String clientMessage; diff --git a/src/main/java/org/runimo/runimo/external/FileStorageService.java b/src/main/java/org/runimo/runimo/external/FileStorageService.java new file mode 100644 index 00000000..131dc72c --- /dev/null +++ b/src/main/java/org/runimo/runimo/external/FileStorageService.java @@ -0,0 +1,47 @@ +package org.runimo.runimo.external; + +import java.net.URL; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileStorageService { + + private final S3Service s3Service; + + + public String storeFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + log.debug("저장할 파일이 없습니다."); + return null; + } + + try { + log.debug("파일 업로드 시작: {}", file.getOriginalFilename()); + String fileUrl = s3Service.uploadFile(file); + log.debug("파일 업로드 완료: {}", fileUrl); + return fileUrl; + } catch (Exception e) { + log.error("파일 저장 중 오류 발생: {}", e.getMessage()); + throw new RuntimeException("파일 저장 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } + + public URL generatePresignedUrl(String fileName) { + try { + return s3Service.generatePresignedUrl(fileName); + } catch (Exception e) { + log.error("Presigned URL 생성 중 오류 발생: {}", e.getMessage()); + throw new RuntimeException("Presigned URL 생성 중 오류가 발생했습니다: " + e.getMessage(), e); + } + } + + public String getFileUrl(String objectKey) { + return s3Service.getFileUrl(objectKey); + } + +} diff --git a/src/main/java/org/runimo/runimo/external/ImageUploadController.java b/src/main/java/org/runimo/runimo/external/ImageUploadController.java index 79342db1..2e349253 100644 --- a/src/main/java/org/runimo/runimo/external/ImageUploadController.java +++ b/src/main/java/org/runimo/runimo/external/ImageUploadController.java @@ -19,7 +19,7 @@ @RequiredArgsConstructor public class ImageUploadController { - private final S3Service s3Service; + private final FileStorageService fileStorageService; @Operation(summary = "Presigned URL 발급", description = "Presigned URL을 발급합니다.") @ApiResponses(value = { @@ -32,7 +32,7 @@ public class ImageUploadController { public ResponseEntity> upload( @RequestBody ImageUploadRequest request ) { - URL presignedUrl = s3Service.generatePresignedUrl(request.fileName()); + URL presignedUrl = fileStorageService.generatePresignedUrl(request.fileName()); return ResponseEntity.status(201) .body(SuccessResponse.of( ExternalResponseCode.PRESIGNED_URL_FETCHED, diff --git a/src/main/java/org/runimo/runimo/external/S3Service.java b/src/main/java/org/runimo/runimo/external/S3Service.java index 039d1ca1..e37fa682 100644 --- a/src/main/java/org/runimo/runimo/external/S3Service.java +++ b/src/main/java/org/runimo/runimo/external/S3Service.java @@ -1,5 +1,6 @@ package org.runimo.runimo.external; +import java.io.IOException; import java.net.URL; import java.time.Duration; import java.util.UUID; @@ -7,7 +8,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -18,12 +23,13 @@ public class S3Service { private final S3Presigner s3Presigner; + private final S3Client s3Client; @Value("${cloud.aws.s3.bucket}") private String bucketName; public URL generatePresignedUrl(String fileName) { validateFileName(fileName); - String objectKey = "uploads/" + UUID.randomUUID() + "_" + sanitizeFileName(fileName); + String objectKey = generateObjectKey(fileName); try { PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() .signatureDuration(Duration.ofMinutes(10)) // The URL expires in 10 minutes. @@ -38,6 +44,46 @@ public URL generatePresignedUrl(String fileName) { } } + public String uploadFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("파일이 비어있습니다."); + } + + validateFileName(file.getOriginalFilename()); + String objectKey = generateObjectKey(file.getOriginalFilename()); + + try { + PutObjectRequest putObjectRequest = createPutObjectRequest(bucketName, objectKey); + + // 파일 데이터로 RequestBody 생성 + RequestBody requestBody = RequestBody.fromInputStream( + file.getInputStream(), + file.getSize() + ); + + // S3에 파일 업로드 + PutObjectResponse response = s3Client.putObject(putObjectRequest, requestBody); + log.debug("파일 업로드 완료: {}, ETag: {}", objectKey, response.eTag()); + + // 파일 URL 생성 및 반환 + return getFileUrl(objectKey); + } catch (IOException e) { + log.error("파일 업로드 중 I/O 오류 발생: {}", e.getMessage()); + throw ExternalServiceException.of(ExternalResponseCode.FILE_UPLOAD_FAILED); + } catch (Exception e) { + log.error("파일 업로드 중 오류 발생: {}", e.getMessage()); + throw ExternalServiceException.of(ExternalResponseCode.FILE_UPLOAD_FAILED); + } + } + + public String getFileUrl(String objectKey) { + return "https://" + bucketName + ".s3.amazonaws.com/" + objectKey; + } + + private String generateObjectKey(String fileName) { + return "uploads/" + UUID.randomUUID() + "_" + sanitizeFileName(fileName); + } + private void validateFileName(String fileName) { if (fileName == null || fileName.isEmpty()) { throw new IllegalArgumentException("파일명이 비어있습니다."); diff --git a/src/main/java/org/runimo/runimo/user/domain/Gender.java b/src/main/java/org/runimo/runimo/user/domain/Gender.java index 96603795..b63bce44 100644 --- a/src/main/java/org/runimo/runimo/user/domain/Gender.java +++ b/src/main/java/org/runimo/runimo/user/domain/Gender.java @@ -1,5 +1,7 @@ package org.runimo.runimo.user.domain; -public enum Gender { +import java.io.Serializable; + +public enum Gender implements Serializable { MALE, FEMALE, UNKNOWN; } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f87a1c70..de53c9b2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,12 @@ runimo: iv: ${AES_IV} spring: + servlet: + multipart: + enabled: true + max-request-size: 15MB + max-file-size: 10MB + config: import: - optional:file:${ENV_PATH:.}/.env.${SPRING_PROFILES_ACTIVE:dev}[.properties] diff --git a/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java index 6f452793..7f649bdc 100644 --- a/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java +++ b/src/test/java/org/runimo/runimo/auth/controller/AuthControllerTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -134,10 +135,10 @@ class AuthControllerTest { .willThrow(UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID)); // when & then - mockMvc.perform(post("/api/v1/auth/signup") - .contentType(MediaType.APPLICATION_JSON) - .content( - "{\"register_token\":\"invalid-token\", \"nickname\":\"RunimoUser\", \"img_url\":\"https://example.com/image.jpg\"}")) + mockMvc.perform( + multipart( + "/api/v1/auth/signup") + .param("request", "{\"registerToken\":\"invalid-token\", \"nickname\":\"RunimoUser\"}")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.code").value(UserHttpResponseCode.TOKEN_INVALID.getCode())) .andExpect( diff --git a/src/test/java/org/runimo/runimo/rewards/RewardTest.java b/src/test/java/org/runimo/runimo/rewards/RewardTest.java index 42d7fe03..f244d068 100644 --- a/src/test/java/org/runimo/runimo/rewards/RewardTest.java +++ b/src/test/java/org/runimo/runimo/rewards/RewardTest.java @@ -74,7 +74,7 @@ void setUp() { null, SocialProvider.KAKAO )); - UserSignupCommand command = new UserSignupCommand(registerToken, "name", "1234", Gender.UNKNOWN); + UserSignupCommand command = new UserSignupCommand(registerToken, "name", null, Gender.UNKNOWN); Long useId = signUpUsecaseImpl.register(command).userId(); savedUser = userRepository.findById(useId).orElse(null); } diff --git a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java index b5494454..742272b1 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java @@ -150,13 +150,12 @@ void tearDown() { AuthSignupRequest request = new AuthSignupRequest( registerToken, "test-user", - "https://test-image.com", Gender.FEMALE ); ValidatableResponse res = given() - .body(objectMapper.writeValueAsString(request)) - .contentType(ContentType.JSON) + .multiPart("request", objectMapper.writeValueAsString(request)) + .contentType("multipart/form-data") .when() .post("/api/v1/auth/signup") .then()