diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java index 55bbcda1..3392f07c 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java @@ -22,6 +22,10 @@ public record RecruitCoreApplicantDetailResponse( ) { public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity) { + return from(entity, entity.getFileUrls()); + } + + public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity, List fileUrls) { return new RecruitCoreApplicantDetailResponse( entity.getId(), entity.getSession(), @@ -31,7 +35,7 @@ public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication ent entity.getWish(), entity.getStrengths(), entity.getPledge(), - entity.getFileUrls(), + fileUrls, entity.getResultStatus(), RecruitCoreApplicationReviewResponse.from(entity), entity.getCreatedAt(), diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java index 16f58237..22de5790 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -13,6 +13,7 @@ import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException; import inha.gdgoc.domain.recruit.core.exception.RecruitCoreClosedException; import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.resource.service.S3Service; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.repository.UserRepository; @@ -35,11 +36,12 @@ public class RecruitCoreApplicationService { private final UserRepository userRepository; private final RecruitCoreSessionResolver recruitCoreSessionResolver; private final MajorNormalizer majorNormalizer; + private final S3Service s3Service; @Transactional(readOnly = true) public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { RecruitCoreApplication app = getApplication(id); - return RecruitCoreApplicantDetailResponse.from(app); + return RecruitCoreApplicantDetailResponse.from(app, toS3FileUrls(app.getFileUrls())); } @Transactional(readOnly = true) @@ -119,7 +121,23 @@ public RecruitCoreApplicantDetailResponse getApplicantDetailForViewer( if (!privileged && !application.isOwnedBy(viewerId)) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); } - return RecruitCoreApplicantDetailResponse.from(application); + return RecruitCoreApplicantDetailResponse.from(application, toS3FileUrls(application.getFileUrls())); + } + + private List toS3FileUrls(List fileKeys) { + if (fileKeys == null || fileKeys.isEmpty()) { + return List.of(); + } + return fileKeys.stream() + .map(this::toS3FileUrl) + .toList(); + } + + private String toS3FileUrl(String fileKey) { + if (fileKey.startsWith("http://") || fileKey.startsWith("https://")) { + return fileKey; + } + return s3Service.getS3FileUrl(fileKey); } private RecruitCoreApplication getApplication(Long id) { diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 0121afe9..48b55657 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -20,6 +20,7 @@ import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.recruit.member.dto.response.RecruitMemberSummaryResponse; import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse; import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import inha.gdgoc.domain.recruit.member.service.RecruitMemberService; import inha.gdgoc.global.dto.response.ApiResponse; @@ -29,6 +30,9 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -67,6 +71,13 @@ public class RecruitMemberController { private final RecruitMemberService recruitMemberService; + public record ProofFilePresignedUploadRequest( + @NotBlank String fileName, + @NotBlank String contentType, + @NotNull @PositiveOrZero Long fileSize + ) { + } + @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> recruitMemberAdd( @RequestBody Map applicationRequest @@ -86,6 +97,19 @@ public ResponseEntity> recruitMemberAddMultipart( return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); } + @PostMapping("/apply/proof-file/presigned-upload") + public ResponseEntity> createProofFilePresignedUpload( + @Valid @RequestBody ProofFilePresignedUploadRequest request + ) { + PresignedUploadResponse response = recruitMemberService.createProofFilePresignedUpload( + request.fileName(), + request.contentType(), + request.fileSize() + ); + + return ResponseEntity.ok(ApiResponse.ok("증빙 파일 업로드 URL을 발급했습니다.", response)); + } + @PostMapping("/memo") public ResponseEntity> recruitMemberMemoAdd( @Valid @RequestBody RecruitMemberMemoRequest recruitMemberMemoRequest diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java index d895f756..8966bf79 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -5,6 +5,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import inha.gdgoc.domain.resource.enums.S3KeyType; +import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse; +import inha.gdgoc.domain.resource.exception.ResourceErrorCode; +import inha.gdgoc.domain.resource.exception.ResourceException; import inha.gdgoc.domain.resource.service.S3Service; import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; import inha.gdgoc.domain.recruit.member.dto.request.RecruitMemberMemoRequest; @@ -36,6 +39,8 @@ @RequiredArgsConstructor @Service public class RecruitMemberService { + private static final long MAX_PROOF_FILE_SIZE = 10 * 1024 * 1024; + private final RecruitMemberRepository recruitMemberRepository; private final RecruitMemberMemoRepository recruitMemberMemoRepository; private final AnswerRepository answerRepository; @@ -63,6 +68,7 @@ public void addRecruitMember(Map requestPayload, MultipartFile f String proofFileUrl = s3Service.getS3FileUrl(key); answers.put("proofFileUrl", proofFileUrl); } + normalizeProofFileUrl(answers); RecruitMember member = memberRequest .toEntity(semesterCalculator.currentSemester(), majorNormalizer); @@ -86,12 +92,30 @@ public void addRecruitMember(Map requestPayload, MultipartFile f private String uploadProofFile(MultipartFile file) { try { - return s3Service.upload(0L, S3KeyType.study, file); + return s3Service.upload(0L, S3KeyType.recruitMember, file); } catch (Exception e) { throw new RuntimeException("증빙 파일 업로드 중 오류가 발생했습니다.", e); } } + public PresignedUploadResponse createProofFilePresignedUpload( + String fileName, + String contentType, + Long fileSize + ) { + if (fileSize == null || fileSize > MAX_PROOF_FILE_SIZE) { + throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); + } + + S3Service.PresignedUpload presignedUpload = s3Service.createPresignedUpload( + 0L, + S3KeyType.recruitMember, + fileName, + contentType + ); + return new PresignedUploadResponse(presignedUpload.key(), presignedUpload.uploadUrl()); + } + @Transactional public void addRecruitMemberMemo(RecruitMemberMemoRequest recruitMemberMemoRequest) { String cleanPhone = normalizePhoneNumber(recruitMemberMemoRequest.getPhoneNumber()); @@ -187,6 +211,7 @@ private Map buildAnswersFromNumberedPayload(Map putIfPresent(answers, "gdgInterest", step6.get("gdgInterest")); putIfPresent(answers, "gdgWish", step6.get("gdgWish")); putIfPresent(answers, "gdgFeedback", step6.get("gdgFeedback")); + putIfPresent(answers, "proofFileUrl", step6.get("proofFileUrl")); return answers; } @@ -213,7 +238,19 @@ private Map normalizeAnswers(Map rawAnswers) { putIfPresent(answers, "gdgInterest", rawAnswers.get("gdgInterest")); putIfPresent(answers, "gdgWish", rawAnswers.get("gdgWish")); putIfPresent(answers, "gdgFeedback", rawAnswers.get("gdgFeedback")); + putIfPresent(answers, "proofFileUrl", rawAnswers.get("proofFileUrl")); return answers; } + private void normalizeProofFileUrl(Map answers) { + Object proofFileUrl = answers.get("proofFileUrl"); + if (!(proofFileUrl instanceof String fileUrl) || fileUrl.isBlank()) { + return; + } + if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) { + return; + } + answers.put("proofFileUrl", s3Service.getS3FileUrl(fileUrl)); + } + } diff --git a/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java index da2d07d0..4704239d 100644 --- a/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java +++ b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java @@ -2,13 +2,17 @@ import static inha.gdgoc.domain.resource.controller.message.ResourceMessage.IMAGE_SAVE_SUCCESS; +import inha.gdgoc.domain.resource.dto.request.PresignedUploadRequest; +import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse; import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; import inha.gdgoc.domain.resource.enums.S3KeyType; import inha.gdgoc.domain.resource.service.ResourceService; import inha.gdgoc.global.dto.response.ApiResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -31,4 +35,13 @@ public ResponseEntity> uploadImage( S3ResultResponse response = resourceService.uploadImage(authentication, file, s3key); return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response)); } + + @PostMapping("/presigned-upload") + public ResponseEntity> createPresignedUpload( + Authentication authentication, + @Valid @RequestBody PresignedUploadRequest request + ) { + PresignedUploadResponse response = resourceService.createPresignedUpload(authentication, request); + return ResponseEntity.ok(ApiResponse.ok("파일 업로드 URL을 발급했습니다.", response)); + } } diff --git a/src/main/java/inha/gdgoc/domain/resource/dto/request/PresignedUploadRequest.java b/src/main/java/inha/gdgoc/domain/resource/dto/request/PresignedUploadRequest.java new file mode 100644 index 00000000..157c88f8 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/resource/dto/request/PresignedUploadRequest.java @@ -0,0 +1,14 @@ +package inha.gdgoc.domain.resource.dto.request; + +import inha.gdgoc.domain.resource.enums.S3KeyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +public record PresignedUploadRequest( + @NotBlank String fileName, + @NotBlank String contentType, + @NotNull @PositiveOrZero Long fileSize, + @NotNull S3KeyType s3key +) { +} diff --git a/src/main/java/inha/gdgoc/domain/resource/dto/response/PresignedUploadResponse.java b/src/main/java/inha/gdgoc/domain/resource/dto/response/PresignedUploadResponse.java new file mode 100644 index 00000000..52b8c79c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/resource/dto/response/PresignedUploadResponse.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.resource.dto.response; + +public record PresignedUploadResponse( + String key, + String uploadUrl +) { +} diff --git a/src/main/java/inha/gdgoc/domain/resource/enums/S3KeyType.java b/src/main/java/inha/gdgoc/domain/resource/enums/S3KeyType.java index 1ef5c71f..be20608a 100644 --- a/src/main/java/inha/gdgoc/domain/resource/enums/S3KeyType.java +++ b/src/main/java/inha/gdgoc/domain/resource/enums/S3KeyType.java @@ -5,7 +5,9 @@ @Getter public enum S3KeyType { - study("study"); + study("study"), + recruitCore("recruit/core"), + recruitMember("recruit/member"); private final String value; diff --git a/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java index 4ce1d077..365a2bf5 100644 --- a/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java +++ b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java @@ -1,6 +1,8 @@ package inha.gdgoc.domain.resource.service; import inha.gdgoc.domain.auth.service.AuthService; +import inha.gdgoc.domain.resource.dto.request.PresignedUploadRequest; +import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse; import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; import inha.gdgoc.domain.resource.enums.S3KeyType; import inha.gdgoc.domain.resource.exception.ResourceErrorCode; @@ -35,4 +37,23 @@ public S3ResultResponse uploadImage(Authentication authentication, MultipartFile throw new ResourceException(ResourceErrorCode.RESOURCE_UPLOAD_FAILED); } } + + @Transactional(readOnly = true) + public PresignedUploadResponse createPresignedUpload( + Authentication authentication, + PresignedUploadRequest request + ) { + if (request.fileSize() > MAX_FILE_SIZE) { + throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); + } + + Long userId = authService.getAuthenticationUserId(authentication); + S3Service.PresignedUpload presignedUpload = s3Service.createPresignedUpload( + userId, + request.s3key(), + request.fileName(), + request.contentType() + ); + return new PresignedUploadResponse(presignedUpload.key(), presignedUpload.uploadUrl()); + } } diff --git a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java index 6f4a2b29..8e71b467 100644 --- a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java +++ b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java @@ -3,6 +3,7 @@ import inha.gdgoc.domain.resource.enums.S3KeyType; import inha.gdgoc.global.config.s3.S3Properties; import java.io.IOException; +import java.time.Duration; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -11,17 +12,20 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetUrlRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +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; @Service @RequiredArgsConstructor public class S3Service { private final S3Client s3Client; + private final S3Presigner s3Presigner; private final S3Properties s3Properties; public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IOException { - String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename(); - String key = "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName); + String key = buildKey(userId, s3key, file.getOriginalFilename()); PutObjectRequest putReq = PutObjectRequest.builder() .bucket(s3Properties.getBucket()) @@ -33,9 +37,43 @@ public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IO return key; } + public PresignedUpload createPresignedUpload( + Long userId, + S3KeyType s3key, + String originalFileName, + String contentType + ) { + String safeContentType = contentType == null || contentType.isBlank() + ? "application/octet-stream" + : contentType; + String key = buildKey(userId, s3key, originalFileName); + PutObjectRequest putReq = PutObjectRequest.builder() + .bucket(s3Properties.getBucket()) + .key(key) + .contentType(safeContentType) + .build(); + PutObjectPresignRequest presignReq = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(5)) + .putObjectRequest(putReq) + .build(); + PresignedPutObjectRequest presignedReq = s3Presigner.presignPutObject(presignReq); + return new PresignedUpload(key, presignedReq.url().toExternalForm()); + } + public String getS3FileUrl(String key) { return s3Client.utilities() .getUrl(GetUrlRequest.builder().bucket(s3Properties.getBucket()).key(key).build()) .toExternalForm(); } + + private String buildKey(Long userId, S3KeyType s3key, String originalFileName) { + String safeFileName = originalFileName == null || originalFileName.isBlank() + ? "file" + : originalFileName.replaceAll("[\\\\/]", "_"); + String fileName = UUID.randomUUID() + "-" + safeFileName; + return "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName); + } + + public record PresignedUpload(String key, String uploadUrl) { + } } diff --git a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java index 14d27a0d..ccd51edf 100644 --- a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java +++ b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java @@ -7,6 +7,7 @@ import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { @@ -25,4 +26,9 @@ public AwsCredentialsProvider awsCredentialsProvider() { public S3Client s3Client(Region region, AwsCredentialsProvider provider) { return S3Client.builder().region(region).credentialsProvider(provider).build(); } + + @Bean + public S3Presigner s3Presigner(Region region, AwsCredentialsProvider provider) { + return S3Presigner.builder().region(region).credentialsProvider(provider).build(); + } }