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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,11 +90,13 @@ public ResponseEntity<Response> appleLogin(
schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "이미 존재하는 사용자")
})
@PostMapping("/signup")
@PostMapping(value = "/signup", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<SuccessResponse<SignupUserResponse>> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, AuthSignupRequest> {

private final ObjectMapper objectMapper;
@SneakyThrows
@Override
public AuthSignupRequest convert(@NotNull String source) {
return objectMapper.readValue(source, AuthSignupRequest.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {

Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/runimo/runimo/config/S3Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}
}
8 changes: 8 additions & 0 deletions src/main/java/org/runimo/runimo/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<HandlerMethodArgumentResolver> resolvers) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/main/java/org/runimo/runimo/external/FileStorageService.java
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -32,7 +32,7 @@ public class ImageUploadController {
public ResponseEntity<SuccessResponse<String>> 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,
Expand Down
48 changes: 47 additions & 1 deletion src/main/java/org/runimo/runimo/external/S3Service.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package org.runimo.runimo.external;

import java.io.IOException;
import java.net.URL;
import java.time.Duration;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
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;
Expand All @@ -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.
Expand All @@ -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("파일명이 비어있습니다.");
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/org/runimo/runimo/user/domain/Gender.java
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/test/java/org/runimo/runimo/rewards/RewardTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading