diff --git a/backend/build.gradle b/backend/build.gradle index 640f73513..4c4a2bcbd 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -39,6 +39,11 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + implementation platform('software.amazon.awssdk:bom:2.27.21') + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:sso' + implementation 'software.amazon.awssdk:ssooidc' + runtimeOnly 'com.mysql:mysql-connector-j' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/api/InterviewController.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/api/InterviewController.java index 22be703cc..9466d0fa2 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/interview/api/InterviewController.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/api/InterviewController.java @@ -12,6 +12,7 @@ import com.shyashyashya.refit.domain.interview.dto.request.QnaSetCreateRequest; import com.shyashyashya.refit.domain.interview.dto.request.RawTextUpdateRequest; import com.shyashyashya.refit.domain.interview.dto.response.GuideQuestionResponse; +import com.shyashyashya.refit.domain.interview.dto.response.PdfUploadUrlResponse; import com.shyashyashya.refit.domain.interview.dto.response.QnaSetCreateResponse; import com.shyashyashya.refit.domain.interview.service.GuideQuestionService; import com.shyashyashya.refit.domain.interview.service.InterviewService; @@ -152,4 +153,12 @@ public ResponseEntity> completeSelfReview(@PathVariable Long i var response = ApiResponse.success(COMMON200); return ResponseEntity.ok(response); } + + @Operation(summary = "면접 PDF 파일 업로드를 위한 Pre-Signed URL을 요청합니다.") + @GetMapping("/{interviewId}/pdf/upload-url") + public ResponseEntity> createUploadUrl(@PathVariable Long interviewId) { + var body = interviewService.createPdfUploadUrl(interviewId); + var response = ApiResponse.success(COMMON200, body); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/dto/response/PdfUploadUrlResponse.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/dto/response/PdfUploadUrlResponse.java new file mode 100644 index 000000000..8297f2d20 --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/dto/response/PdfUploadUrlResponse.java @@ -0,0 +1,6 @@ +package com.shyashyashya.refit.domain.interview.dto.response; + +import jakarta.validation.constraints.NotNull; + +public record PdfUploadUrlResponse( + @NotNull String url, @NotNull String key) {} diff --git a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java index 1e04a0cc4..a209f7511 100644 --- a/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java +++ b/backend/src/main/java/com/shyashyashya/refit/domain/interview/service/InterviewService.java @@ -19,6 +19,7 @@ import com.shyashyashya.refit.domain.interview.dto.request.KptSelfReviewUpdateRequest; import com.shyashyashya.refit.domain.interview.dto.request.QnaSetCreateRequest; import com.shyashyashya.refit.domain.interview.dto.request.RawTextUpdateRequest; +import com.shyashyashya.refit.domain.interview.dto.response.PdfUploadUrlResponse; import com.shyashyashya.refit.domain.interview.dto.response.QnaSetCreateResponse; import com.shyashyashya.refit.domain.interview.model.Interview; import com.shyashyashya.refit.domain.interview.model.InterviewReviewStatus; @@ -35,7 +36,9 @@ import com.shyashyashya.refit.domain.qnaset.repository.StarAnalysisRepository; import com.shyashyashya.refit.domain.user.model.User; import com.shyashyashya.refit.global.exception.CustomException; +import com.shyashyashya.refit.global.property.S3Property; import com.shyashyashya.refit.global.util.RequestUserContext; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -47,6 +50,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +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 @@ -63,6 +70,8 @@ public class InterviewService { private final InterviewValidator interviewValidator; private final RequestUserContext requestUserContext; + private final S3Presigner s3Presigner; + private final S3Property s3Property; @Transactional(readOnly = true) public InterviewDto getInterview(Long interviewId) { @@ -165,6 +174,32 @@ public Page searchMyInterviews(InterviewSearchRequest request, Pag .map(InterviewDto::from); } + @Transactional(readOnly = true) + public PdfUploadUrlResponse createPdfUploadUrl(Long interviewId) { + User requestUser = requestUserContext.getRequestUser(); + Interview interview = + interviewRepository.findById(interviewId).orElseThrow(() -> new CustomException(INTERVIEW_NOT_FOUND)); + interviewValidator.validateInterviewOwner(interview, requestUser); + + String extension = ".pdf"; + String key = s3Property.prefix() + interviewId + extension; + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Property.bucket()) + .key(key) + .contentType("application/pdf") + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(s3Property.presignExpireSeconds())) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(presignRequest); + + return new PdfUploadUrlResponse(presigned.url().toString(), key); + } + public Page getMyInterviewDrafts(InterviewDraftType draftType, Pageable pageable) { User requestUser = requestUserContext.getRequestUser(); diff --git a/backend/src/main/java/com/shyashyashya/refit/global/config/S3Config.java b/backend/src/main/java/com/shyashyashya/refit/global/config/S3Config.java new file mode 100644 index 000000000..d1cde9feb --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/config/S3Config.java @@ -0,0 +1,24 @@ +package com.shyashyashya.refit.global.config; + +import com.shyashyashya.refit.global.property.S3Property; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final S3Property s3Property; + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(s3Property.region())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} diff --git a/backend/src/main/java/com/shyashyashya/refit/global/property/S3Property.java b/backend/src/main/java/com/shyashyashya/refit/global/property/S3Property.java new file mode 100644 index 000000000..e1436ba83 --- /dev/null +++ b/backend/src/main/java/com/shyashyashya/refit/global/property/S3Property.java @@ -0,0 +1,13 @@ +package com.shyashyashya.refit.global.property; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@ConfigurationProperties(prefix = "spring.s3") +@Validated +public record S3Property( + @NotBlank String region, + @NotBlank String bucket, + @NotBlank String prefix, + int presignExpireSeconds) {} diff --git a/backend/src/main/resources/application-s3.yml b/backend/src/main/resources/application-s3.yml index e69de29bb..f53e8f13a 100644 --- a/backend/src/main/resources/application-s3.yml +++ b/backend/src/main/resources/application-s3.yml @@ -0,0 +1,6 @@ +spring: + s3: + region: "${AWS_S3_REGION}" + bucket: "${AWS_S3_BUCKET}" + prefix: "${AWS_S3_PREFIX}" + presign-expire-seconds: "${AWS_S3_PRESIGN_EXPIRE_SECONDS}" diff --git a/backend/src/test/resources/application-s3.yml b/backend/src/test/resources/application-s3.yml new file mode 100644 index 000000000..422a047af --- /dev/null +++ b/backend/src/test/resources/application-s3.yml @@ -0,0 +1,6 @@ +spring: + s3: + region: "dummy" + bucket: "dummy" + prefix: "dummy" + presign-expire-seconds: 0 diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index f9b1ea61d..85318f3c3 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -6,3 +6,4 @@ spring: include: - auth - gemini + - s3