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 @@ -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<String> fileUrls) {
return new RecruitCoreApplicantDetailResponse(
entity.getId(),
entity.getSession(),
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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<String> toS3FileUrls(List<String> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<ApiResponse<Void, Void>> recruitMemberAdd(
@RequestBody Map<String, Object> applicationRequest
Expand All @@ -86,6 +97,19 @@ public ResponseEntity<ApiResponse<Void, Void>> recruitMemberAddMultipart(
return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS));
}

@PostMapping("/apply/proof-file/presigned-upload")
public ResponseEntity<ApiResponse<PresignedUploadResponse, Void>> 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<ApiResponse<Void, Void>> recruitMemberMemoAdd(
@Valid @RequestBody RecruitMemberMemoRequest recruitMemberMemoRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -63,6 +68,7 @@ public void addRecruitMember(Map<String, Object> requestPayload, MultipartFile f
String proofFileUrl = s3Service.getS3FileUrl(key);
answers.put("proofFileUrl", proofFileUrl);
}
normalizeProofFileUrl(answers);

RecruitMember member = memberRequest
.toEntity(semesterCalculator.currentSemester(), majorNormalizer);
Expand All @@ -86,12 +92,30 @@ public void addRecruitMember(Map<String, Object> 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());
Expand Down Expand Up @@ -187,6 +211,7 @@ private Map<String, Object> buildAnswersFromNumberedPayload(Map<String, Object>
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;
}
Expand All @@ -213,7 +238,19 @@ private Map<String, Object> normalizeAnswers(Map<String, Object> 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<String, Object> 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));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,4 +35,13 @@ public ResponseEntity<ApiResponse<S3ResultResponse, Void>> uploadImage(
S3ResultResponse response = resourceService.uploadImage(authentication, file, s3key);
return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response));
}

@PostMapping("/presigned-upload")
public ResponseEntity<ApiResponse<PresignedUploadResponse, Void>> createPresignedUpload(
Authentication authentication,
@Valid @RequestBody PresignedUploadRequest request
) {
PresignedUploadResponse response = resourceService.createPresignedUpload(authentication, request);
return ResponseEntity.ok(ApiResponse.ok("파일 μ—…λ‘œλ“œ URL을 λ°œκΈ‰ν–ˆμŠ΅λ‹ˆλ‹€.", response));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package inha.gdgoc.domain.resource.dto.response;

public record PresignedUploadResponse(
String key,
String uploadUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
@Getter
public enum S3KeyType {

study("study");
study("study"),
recruitCore("recruit/core"),
recruitMember("recruit/member");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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());
}
}
42 changes: 40 additions & 2 deletions src/main/java/inha/gdgoc/domain/resource/service/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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())
Expand All @@ -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) {
}
}
6 changes: 6 additions & 0 deletions src/main/java/inha/gdgoc/global/config/s3/S3Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}
}
Loading