Skip to content

Commit ec9ca92

Browse files
committed
feat: add presigned upload support for recruit files
1 parent ffcceba commit ec9ca92

11 files changed

Lines changed: 191 additions & 7 deletions

File tree

src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ public record RecruitCoreApplicantDetailResponse(
2222
) {
2323

2424
public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity) {
25+
return from(entity, entity.getFileUrls());
26+
}
27+
28+
public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity, List<String> fileUrls) {
2529
return new RecruitCoreApplicantDetailResponse(
2630
entity.getId(),
2731
entity.getSession(),
@@ -31,7 +35,7 @@ public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication ent
3135
entity.getWish(),
3236
entity.getStrengths(),
3337
entity.getPledge(),
34-
entity.getFileUrls(),
38+
fileUrls,
3539
entity.getResultStatus(),
3640
RecruitCoreApplicationReviewResponse.from(entity),
3741
entity.getCreatedAt(),

src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException;
1414
import inha.gdgoc.domain.recruit.core.exception.RecruitCoreClosedException;
1515
import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository;
16+
import inha.gdgoc.domain.resource.service.S3Service;
1617
import inha.gdgoc.domain.user.entity.User;
1718
import inha.gdgoc.domain.user.enums.UserRole;
1819
import inha.gdgoc.domain.user.repository.UserRepository;
@@ -35,11 +36,12 @@ public class RecruitCoreApplicationService {
3536
private final UserRepository userRepository;
3637
private final RecruitCoreSessionResolver recruitCoreSessionResolver;
3738
private final MajorNormalizer majorNormalizer;
39+
private final S3Service s3Service;
3840

3941
@Transactional(readOnly = true)
4042
public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) {
4143
RecruitCoreApplication app = getApplication(id);
42-
return RecruitCoreApplicantDetailResponse.from(app);
44+
return RecruitCoreApplicantDetailResponse.from(app, toS3FileUrls(app.getFileUrls()));
4345
}
4446

4547
@Transactional(readOnly = true)
@@ -119,7 +121,23 @@ public RecruitCoreApplicantDetailResponse getApplicantDetailForViewer(
119121
if (!privileged && !application.isOwnedBy(viewerId)) {
120122
throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER);
121123
}
122-
return RecruitCoreApplicantDetailResponse.from(application);
124+
return RecruitCoreApplicantDetailResponse.from(application, toS3FileUrls(application.getFileUrls()));
125+
}
126+
127+
private List<String> toS3FileUrls(List<String> fileKeys) {
128+
if (fileKeys == null || fileKeys.isEmpty()) {
129+
return List.of();
130+
}
131+
return fileKeys.stream()
132+
.map(this::toS3FileUrl)
133+
.toList();
134+
}
135+
136+
private String toS3FileUrl(String fileKey) {
137+
if (fileKey.startsWith("http://") || fileKey.startsWith("https://")) {
138+
return fileKey;
139+
}
140+
return s3Service.getS3FileUrl(fileKey);
123141
}
124142

125143
private RecruitCoreApplication getApplication(Long id) {

src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse;
2121
import inha.gdgoc.domain.recruit.member.dto.response.RecruitMemberSummaryResponse;
2222
import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse;
23+
import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse;
2324
import inha.gdgoc.domain.recruit.member.entity.RecruitMember;
2425
import inha.gdgoc.domain.recruit.member.service.RecruitMemberService;
2526
import inha.gdgoc.global.dto.response.ApiResponse;
@@ -29,6 +30,9 @@
2930
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
3031
import io.swagger.v3.oas.annotations.tags.Tag;
3132
import jakarta.validation.Valid;
33+
import jakarta.validation.constraints.NotBlank;
34+
import jakarta.validation.constraints.NotNull;
35+
import jakarta.validation.constraints.PositiveOrZero;
3236
import java.util.List;
3337
import lombok.RequiredArgsConstructor;
3438
import org.springframework.data.domain.Page;
@@ -67,6 +71,13 @@ public class RecruitMemberController {
6771

6872
private final RecruitMemberService recruitMemberService;
6973

74+
public record ProofFilePresignedUploadRequest(
75+
@NotBlank String fileName,
76+
@NotBlank String contentType,
77+
@NotNull @PositiveOrZero Long fileSize
78+
) {
79+
}
80+
7081
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
7182
public ResponseEntity<ApiResponse<Void, Void>> recruitMemberAdd(
7283
@RequestBody Map<String, Object> applicationRequest
@@ -86,6 +97,19 @@ public ResponseEntity<ApiResponse<Void, Void>> recruitMemberAddMultipart(
8697
return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS));
8798
}
8899

100+
@PostMapping("/apply/proof-file/presigned-upload")
101+
public ResponseEntity<ApiResponse<PresignedUploadResponse, Void>> createProofFilePresignedUpload(
102+
@Valid @RequestBody ProofFilePresignedUploadRequest request
103+
) {
104+
PresignedUploadResponse response = recruitMemberService.createProofFilePresignedUpload(
105+
request.fileName(),
106+
request.contentType(),
107+
request.fileSize()
108+
);
109+
110+
return ResponseEntity.ok(ApiResponse.ok("증빙 파일 업로드 URL을 발급했습니다.", response));
111+
}
112+
89113
@PostMapping("/memo")
90114
public ResponseEntity<ApiResponse<Void, Void>> recruitMemberMemoAdd(
91115
@Valid @RequestBody RecruitMemberMemoRequest recruitMemberMemoRequest

src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import inha.gdgoc.domain.resource.enums.S3KeyType;
8+
import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse;
9+
import inha.gdgoc.domain.resource.exception.ResourceErrorCode;
10+
import inha.gdgoc.domain.resource.exception.ResourceException;
811
import inha.gdgoc.domain.resource.service.S3Service;
912
import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest;
1013
import inha.gdgoc.domain.recruit.member.dto.request.RecruitMemberMemoRequest;
@@ -36,6 +39,8 @@
3639
@RequiredArgsConstructor
3740
@Service
3841
public class RecruitMemberService {
42+
private static final long MAX_PROOF_FILE_SIZE = 10 * 1024 * 1024;
43+
3944
private final RecruitMemberRepository recruitMemberRepository;
4045
private final RecruitMemberMemoRepository recruitMemberMemoRepository;
4146
private final AnswerRepository answerRepository;
@@ -63,6 +68,7 @@ public void addRecruitMember(Map<String, Object> requestPayload, MultipartFile f
6368
String proofFileUrl = s3Service.getS3FileUrl(key);
6469
answers.put("proofFileUrl", proofFileUrl);
6570
}
71+
normalizeProofFileUrl(answers);
6672

6773
RecruitMember member = memberRequest
6874
.toEntity(semesterCalculator.currentSemester(), majorNormalizer);
@@ -86,12 +92,30 @@ public void addRecruitMember(Map<String, Object> requestPayload, MultipartFile f
8692

8793
private String uploadProofFile(MultipartFile file) {
8894
try {
89-
return s3Service.upload(0L, S3KeyType.study, file);
95+
return s3Service.upload(0L, S3KeyType.recruitMember, file);
9096
} catch (Exception e) {
9197
throw new RuntimeException("증빙 파일 업로드 중 오류가 발생했습니다.", e);
9298
}
9399
}
94100

101+
public PresignedUploadResponse createProofFilePresignedUpload(
102+
String fileName,
103+
String contentType,
104+
Long fileSize
105+
) {
106+
if (fileSize == null || fileSize > MAX_PROOF_FILE_SIZE) {
107+
throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE);
108+
}
109+
110+
S3Service.PresignedUpload presignedUpload = s3Service.createPresignedUpload(
111+
0L,
112+
S3KeyType.recruitMember,
113+
fileName,
114+
contentType
115+
);
116+
return new PresignedUploadResponse(presignedUpload.key(), presignedUpload.uploadUrl());
117+
}
118+
95119
@Transactional
96120
public void addRecruitMemberMemo(RecruitMemberMemoRequest recruitMemberMemoRequest) {
97121
String cleanPhone = normalizePhoneNumber(recruitMemberMemoRequest.getPhoneNumber());
@@ -187,6 +211,7 @@ private Map<String, Object> buildAnswersFromNumberedPayload(Map<String, Object>
187211
putIfPresent(answers, "gdgInterest", step6.get("gdgInterest"));
188212
putIfPresent(answers, "gdgWish", step6.get("gdgWish"));
189213
putIfPresent(answers, "gdgFeedback", step6.get("gdgFeedback"));
214+
putIfPresent(answers, "proofFileUrl", step6.get("proofFileUrl"));
190215

191216
return answers;
192217
}
@@ -213,7 +238,19 @@ private Map<String, Object> normalizeAnswers(Map<String, Object> rawAnswers) {
213238
putIfPresent(answers, "gdgInterest", rawAnswers.get("gdgInterest"));
214239
putIfPresent(answers, "gdgWish", rawAnswers.get("gdgWish"));
215240
putIfPresent(answers, "gdgFeedback", rawAnswers.get("gdgFeedback"));
241+
putIfPresent(answers, "proofFileUrl", rawAnswers.get("proofFileUrl"));
216242
return answers;
217243
}
218244

245+
private void normalizeProofFileUrl(Map<String, Object> answers) {
246+
Object proofFileUrl = answers.get("proofFileUrl");
247+
if (!(proofFileUrl instanceof String fileUrl) || fileUrl.isBlank()) {
248+
return;
249+
}
250+
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
251+
return;
252+
}
253+
answers.put("proofFileUrl", s3Service.getS3FileUrl(fileUrl));
254+
}
255+
219256
}

src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
import static inha.gdgoc.domain.resource.controller.message.ResourceMessage.IMAGE_SAVE_SUCCESS;
44

5+
import inha.gdgoc.domain.resource.dto.request.PresignedUploadRequest;
6+
import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse;
57
import inha.gdgoc.domain.resource.dto.response.S3ResultResponse;
68
import inha.gdgoc.domain.resource.enums.S3KeyType;
79
import inha.gdgoc.domain.resource.service.ResourceService;
810
import inha.gdgoc.global.dto.response.ApiResponse;
11+
import jakarta.validation.Valid;
912
import lombok.RequiredArgsConstructor;
1013
import org.springframework.http.ResponseEntity;
1114
import org.springframework.security.core.Authentication;
15+
import org.springframework.web.bind.annotation.RequestBody;
1216
import org.springframework.web.bind.annotation.PostMapping;
1317
import org.springframework.web.bind.annotation.RequestMapping;
1418
import org.springframework.web.bind.annotation.RequestParam;
@@ -31,4 +35,13 @@ public ResponseEntity<ApiResponse<S3ResultResponse, Void>> uploadImage(
3135
S3ResultResponse response = resourceService.uploadImage(authentication, file, s3key);
3236
return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response));
3337
}
38+
39+
@PostMapping("/presigned-upload")
40+
public ResponseEntity<ApiResponse<PresignedUploadResponse, Void>> createPresignedUpload(
41+
Authentication authentication,
42+
@Valid @RequestBody PresignedUploadRequest request
43+
) {
44+
PresignedUploadResponse response = resourceService.createPresignedUpload(authentication, request);
45+
return ResponseEntity.ok(ApiResponse.ok("파일 업로드 URL을 발급했습니다.", response));
46+
}
3447
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package inha.gdgoc.domain.resource.dto.request;
2+
3+
import inha.gdgoc.domain.resource.enums.S3KeyType;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.PositiveOrZero;
7+
8+
public record PresignedUploadRequest(
9+
@NotBlank String fileName,
10+
@NotBlank String contentType,
11+
@NotNull @PositiveOrZero Long fileSize,
12+
@NotNull S3KeyType s3key
13+
) {
14+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package inha.gdgoc.domain.resource.dto.response;
2+
3+
public record PresignedUploadResponse(
4+
String key,
5+
String uploadUrl
6+
) {
7+
}

src/main/java/inha/gdgoc/domain/resource/enums/S3KeyType.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
@Getter
66
public enum S3KeyType {
77

8-
study("study");
8+
study("study"),
9+
recruitCore("recruit/core"),
10+
recruitMember("recruit/member");
911

1012
private final String value;
1113

src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package inha.gdgoc.domain.resource.service;
22

33
import inha.gdgoc.domain.auth.service.AuthService;
4+
import inha.gdgoc.domain.resource.dto.request.PresignedUploadRequest;
5+
import inha.gdgoc.domain.resource.dto.response.PresignedUploadResponse;
46
import inha.gdgoc.domain.resource.dto.response.S3ResultResponse;
57
import inha.gdgoc.domain.resource.enums.S3KeyType;
68
import inha.gdgoc.domain.resource.exception.ResourceErrorCode;
@@ -35,4 +37,23 @@ public S3ResultResponse uploadImage(Authentication authentication, MultipartFile
3537
throw new ResourceException(ResourceErrorCode.RESOURCE_UPLOAD_FAILED);
3638
}
3739
}
40+
41+
@Transactional(readOnly = true)
42+
public PresignedUploadResponse createPresignedUpload(
43+
Authentication authentication,
44+
PresignedUploadRequest request
45+
) {
46+
if (request.fileSize() > MAX_FILE_SIZE) {
47+
throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE);
48+
}
49+
50+
Long userId = authService.getAuthenticationUserId(authentication);
51+
S3Service.PresignedUpload presignedUpload = s3Service.createPresignedUpload(
52+
userId,
53+
request.s3key(),
54+
request.fileName(),
55+
request.contentType()
56+
);
57+
return new PresignedUploadResponse(presignedUpload.key(), presignedUpload.uploadUrl());
58+
}
3859
}

src/main/java/inha/gdgoc/domain/resource/service/S3Service.java

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inha.gdgoc.domain.resource.enums.S3KeyType;
44
import inha.gdgoc.global.config.s3.S3Properties;
55
import java.io.IOException;
6+
import java.time.Duration;
67
import java.util.UUID;
78
import lombok.RequiredArgsConstructor;
89
import org.springframework.stereotype.Service;
@@ -11,17 +12,20 @@
1112
import software.amazon.awssdk.services.s3.S3Client;
1213
import software.amazon.awssdk.services.s3.model.GetUrlRequest;
1314
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
15+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
16+
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
17+
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
1418

1519
@Service
1620
@RequiredArgsConstructor
1721
public class S3Service {
1822

1923
private final S3Client s3Client;
24+
private final S3Presigner s3Presigner;
2025
private final S3Properties s3Properties;
2126

2227
public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IOException {
23-
String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename();
24-
String key = "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName);
28+
String key = buildKey(userId, s3key, file.getOriginalFilename());
2529

2630
PutObjectRequest putReq = PutObjectRequest.builder()
2731
.bucket(s3Properties.getBucket())
@@ -33,9 +37,43 @@ public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IO
3337
return key;
3438
}
3539

40+
public PresignedUpload createPresignedUpload(
41+
Long userId,
42+
S3KeyType s3key,
43+
String originalFileName,
44+
String contentType
45+
) {
46+
String safeContentType = contentType == null || contentType.isBlank()
47+
? "application/octet-stream"
48+
: contentType;
49+
String key = buildKey(userId, s3key, originalFileName);
50+
PutObjectRequest putReq = PutObjectRequest.builder()
51+
.bucket(s3Properties.getBucket())
52+
.key(key)
53+
.contentType(safeContentType)
54+
.build();
55+
PutObjectPresignRequest presignReq = PutObjectPresignRequest.builder()
56+
.signatureDuration(Duration.ofMinutes(5))
57+
.putObjectRequest(putReq)
58+
.build();
59+
PresignedPutObjectRequest presignedReq = s3Presigner.presignPutObject(presignReq);
60+
return new PresignedUpload(key, presignedReq.url().toExternalForm());
61+
}
62+
3663
public String getS3FileUrl(String key) {
3764
return s3Client.utilities()
3865
.getUrl(GetUrlRequest.builder().bucket(s3Properties.getBucket()).key(key).build())
3966
.toExternalForm();
4067
}
68+
69+
private String buildKey(Long userId, S3KeyType s3key, String originalFileName) {
70+
String safeFileName = originalFileName == null || originalFileName.isBlank()
71+
? "file"
72+
: originalFileName.replaceAll("[\\\\/]", "_");
73+
String fileName = UUID.randomUUID() + "-" + safeFileName;
74+
return "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName);
75+
}
76+
77+
public record PresignedUpload(String key, String uploadUrl) {
78+
}
4179
}

0 commit comments

Comments
 (0)