Skip to content

Commit 5c5df7c

Browse files
authored
feat: S3 파일 업로드 연동 - 프로필 이미지 및 수강 인증 자료
feat: S3 파일 업로드 연동 - 프로필 이미지 및 수강 인증 자료
2 parents 243819e + fca431d commit 5c5df7c

17 files changed

Lines changed: 382 additions & 89 deletions
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.bootsignal.domain.user.storage;
2+
3+
import com.bootsignal.global.config.properties.AwsProperties;
4+
import com.bootsignal.global.config.properties.ProfileImageProperties;
5+
import com.bootsignal.global.exception.BootSignalException;
6+
import com.bootsignal.global.exception.ErrorCode;
7+
import java.io.IOException;
8+
import java.util.Set;
9+
import java.util.UUID;
10+
import org.springframework.util.StringUtils;
11+
import org.springframework.web.multipart.MultipartFile;
12+
import software.amazon.awssdk.core.sync.RequestBody;
13+
import software.amazon.awssdk.services.s3.S3Client;
14+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
15+
16+
public class S3ProfileImageStorage implements ProfileImageStorage {
17+
18+
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
19+
"image/jpeg",
20+
"image/png",
21+
"image/webp"
22+
);
23+
24+
private final S3Client s3Client;
25+
private final AwsProperties awsProperties;
26+
private final ProfileImageProperties profileImageProperties;
27+
28+
public S3ProfileImageStorage(
29+
S3Client s3Client,
30+
AwsProperties awsProperties,
31+
ProfileImageProperties profileImageProperties
32+
) {
33+
if (profileImageProperties.s3() == null
34+
|| !StringUtils.hasText(profileImageProperties.s3().baseUrl())
35+
|| !StringUtils.hasText(profileImageProperties.s3().keyPrefix())) {
36+
throw new IllegalStateException(
37+
"storage-type=s3 설정 시 PROFILE_IMAGE_S3_BASE_URL, PROFILE_IMAGE_S3_KEY_PREFIX 환경 변수가 필요합니다."
38+
);
39+
}
40+
this.s3Client = s3Client;
41+
this.awsProperties = awsProperties;
42+
this.profileImageProperties = profileImageProperties;
43+
}
44+
45+
@Override
46+
public String store(MultipartFile file, Long userId) {
47+
validateFile(file);
48+
49+
String extension = resolveExtension(file);
50+
String key = profileImageProperties.s3().keyPrefix() + "/" + userId + "_" + UUID.randomUUID() + extension;
51+
52+
byte[] bytes;
53+
try {
54+
bytes = file.getBytes();
55+
} catch (IOException exception) {
56+
throw new BootSignalException(ErrorCode.INTERNAL_SERVER_ERROR, "프로필 이미지 저장에 실패했습니다.");
57+
}
58+
59+
try {
60+
s3Client.putObject(
61+
PutObjectRequest.builder()
62+
.bucket(awsProperties.s3().bucket())
63+
.key(key)
64+
.contentType(file.getContentType())
65+
.contentLength(file.getSize())
66+
.build(),
67+
RequestBody.fromBytes(bytes)
68+
);
69+
} catch (Exception exception) {
70+
throw new BootSignalException(ErrorCode.INTERNAL_SERVER_ERROR, "프로필 이미지 저장에 실패했습니다.");
71+
}
72+
73+
String baseUrl = profileImageProperties.s3().baseUrl().replaceAll("/$", "");
74+
return baseUrl + "/" + key;
75+
}
76+
77+
private void validateFile(MultipartFile file) {
78+
if (file == null || file.isEmpty()) {
79+
throw new BootSignalException(ErrorCode.BAD_REQUEST, "프로필 이미지 파일이 필요합니다.");
80+
}
81+
String contentType = file.getContentType();
82+
if (!StringUtils.hasText(contentType) || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
83+
throw new BootSignalException(ErrorCode.BAD_REQUEST, "지원하지 않는 이미지 형식입니다. (jpeg, png, webp)");
84+
}
85+
if (!hasValidImageSignature(file, contentType)) {
86+
throw new BootSignalException(ErrorCode.BAD_REQUEST, "이미지 파일 내용이 형식과 일치하지 않습니다.");
87+
}
88+
}
89+
90+
private String resolveExtension(MultipartFile file) {
91+
return switch (file.getContentType()) {
92+
case "image/png" -> ".png";
93+
case "image/webp" -> ".webp";
94+
default -> ".jpg";
95+
};
96+
}
97+
98+
private boolean hasValidImageSignature(MultipartFile file, String contentType) {
99+
try {
100+
byte[] bytes = file.getBytes();
101+
return switch (contentType) {
102+
case "image/jpeg" -> hasPrefix(bytes, 0xFF, 0xD8, 0xFF);
103+
case "image/png" -> hasPrefix(bytes, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
104+
case "image/webp" -> hasPrefix(bytes, 0x52, 0x49, 0x46, 0x46)
105+
&& hasBytesAt(bytes, 8, 0x57, 0x45, 0x42, 0x50);
106+
default -> false;
107+
};
108+
} catch (IOException exception) {
109+
throw new BootSignalException(ErrorCode.BAD_REQUEST, "이미지 파일을 읽을 수 없습니다.");
110+
}
111+
}
112+
113+
private boolean hasPrefix(byte[] bytes, int... expected) {
114+
return hasBytesAt(bytes, 0, expected);
115+
}
116+
117+
private boolean hasBytesAt(byte[] bytes, int offset, int... expected) {
118+
if (bytes.length < offset + expected.length) {
119+
return false;
120+
}
121+
for (int index = 0; index < expected.length; index++) {
122+
if ((bytes[offset + index] & 0xFF) != expected[index]) {
123+
return false;
124+
}
125+
}
126+
return true;
127+
}
128+
}

src/main/java/com/bootsignal/domain/verification/entity/Verification.java

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import com.bootsignal.domain.course_session.entity.CourseSession;
55
import com.bootsignal.domain.user.entity.User;
66
import com.bootsignal.global.entity.BaseEntity;
7-
import jakarta.persistence.Basic;
87
import jakarta.persistence.Column;
98
import jakarta.persistence.Entity;
109
import jakarta.persistence.EnumType;
@@ -14,7 +13,6 @@
1413
import jakarta.persistence.GenerationType;
1514
import jakarta.persistence.Id;
1615
import jakarta.persistence.JoinColumn;
17-
import jakarta.persistence.Lob;
1816
import jakarta.persistence.ManyToOne;
1917
import jakarta.persistence.Table;
2018
import jakarta.persistence.UniqueConstraint;
@@ -68,10 +66,8 @@ public class Verification extends BaseEntity {
6866
@Column(name = "job_training_history_file_size")
6967
private Long jobTrainingHistoryFileSize;
7068

71-
@Lob
72-
@Basic(fetch = FetchType.LAZY)
73-
@Column(name = "job_training_history_data", columnDefinition = "LONGBLOB")
74-
private byte[] jobTrainingHistoryData;
69+
@Column(name = "job_training_history_s3_key", length = 500)
70+
private String jobTrainingHistoryS3Key;
7571

7672
@Column(name = "online_course_application_file_name", length = 255)
7773
private String onlineCourseApplicationFileName;
@@ -82,10 +78,8 @@ public class Verification extends BaseEntity {
8278
@Column(name = "online_course_application_file_size")
8379
private Long onlineCourseApplicationFileSize;
8480

85-
@Lob
86-
@Basic(fetch = FetchType.LAZY)
87-
@Column(name = "online_course_application_data", columnDefinition = "LONGBLOB")
88-
private byte[] onlineCourseApplicationData;
81+
@Column(name = "online_course_application_s3_key", length = 500)
82+
private String onlineCourseApplicationS3Key;
8983

9084
@Column(name = "reject_reason", columnDefinition = "TEXT")
9185
private String rejectReason;
@@ -108,11 +102,11 @@ private Verification(
108102
String jobTrainingHistoryFileName,
109103
String jobTrainingHistoryContentType,
110104
Long jobTrainingHistoryFileSize,
111-
byte[] jobTrainingHistoryData,
105+
String jobTrainingHistoryS3Key,
112106
String onlineCourseApplicationFileName,
113107
String onlineCourseApplicationContentType,
114108
Long onlineCourseApplicationFileSize,
115-
byte[] onlineCourseApplicationData
109+
String onlineCourseApplicationS3Key
116110
) {
117111
this.user = user;
118112
this.course = course;
@@ -121,11 +115,11 @@ private Verification(
121115
this.jobTrainingHistoryFileName = jobTrainingHistoryFileName;
122116
this.jobTrainingHistoryContentType = jobTrainingHistoryContentType;
123117
this.jobTrainingHistoryFileSize = jobTrainingHistoryFileSize;
124-
this.jobTrainingHistoryData = jobTrainingHistoryData;
118+
this.jobTrainingHistoryS3Key = jobTrainingHistoryS3Key;
125119
this.onlineCourseApplicationFileName = onlineCourseApplicationFileName;
126120
this.onlineCourseApplicationContentType = onlineCourseApplicationContentType;
127121
this.onlineCourseApplicationFileSize = onlineCourseApplicationFileSize;
128-
this.onlineCourseApplicationData = onlineCourseApplicationData;
122+
this.onlineCourseApplicationS3Key = onlineCourseApplicationS3Key;
129123
}
130124

131125
public void approve(User admin, String adminMemo) {

src/main/java/com/bootsignal/domain/verification/service/AdminVerificationService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.bootsignal.domain.verification.entity.VerificationEvidenceType;
99
import com.bootsignal.domain.verification.entity.VerificationStatus;
1010
import com.bootsignal.domain.verification.repository.VerificationRepository;
11+
import com.bootsignal.domain.verification.storage.VerificationFileStorage;
1112
import com.bootsignal.global.exception.BootSignalException;
1213
import com.bootsignal.global.exception.ErrorCode;
1314
import com.bootsignal.global.security.SecurityUtil;
@@ -25,13 +26,16 @@ public class AdminVerificationService {
2526

2627
private final VerificationRepository verificationRepository;
2728
private final UserRepository userRepository;
29+
private final VerificationFileStorage verificationFileStorage;
2830

2931
public AdminVerificationService(
3032
VerificationRepository verificationRepository,
31-
UserRepository userRepository
33+
UserRepository userRepository,
34+
VerificationFileStorage verificationFileStorage
3235
) {
3336
this.verificationRepository = verificationRepository;
3437
this.userRepository = userRepository;
38+
this.verificationFileStorage = verificationFileStorage;
3539
}
3640

3741
public Page<VerificationResponse> getList(VerificationStatus status, Pageable pageable) {
@@ -48,7 +52,7 @@ public VerificationEvidenceFile getEvidenceFile(
4852
VerificationEvidenceType evidenceType
4953
) {
5054
Verification verification = findVerification(verificationId);
51-
return VerificationEvidenceFileResolver.toEvidenceFile(verification, evidenceType);
55+
return VerificationEvidenceFileResolver.toEvidenceFile(verification, evidenceType, verificationFileStorage);
5256
}
5357

5458
@Transactional

src/main/java/com/bootsignal/domain/verification/service/VerificationEvidenceFileResolver.java

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import com.bootsignal.domain.verification.dto.VerificationEvidenceFile;
44
import com.bootsignal.domain.verification.entity.Verification;
55
import com.bootsignal.domain.verification.entity.VerificationEvidenceType;
6+
import com.bootsignal.domain.verification.storage.VerificationFileStorage;
67
import com.bootsignal.global.exception.BootSignalException;
78
import com.bootsignal.global.exception.ErrorCode;
89
import org.springframework.util.StringUtils;
910

1011
/**
11-
* 인증 신청 엔티티에 저장된 자료 유형별 BLOB 데이터를 다운로드 DTO로 변환하는 클래스입니다.
12+
* 인증 신청 엔티티에 저장된 S3 키를 이용해 파일을 다운로드하고 DTO로 변환하는 클래스입니다.
1213
*/
1314
final class VerificationEvidenceFileResolver {
1415

@@ -17,23 +18,33 @@ private VerificationEvidenceFileResolver() {
1718

1819
static VerificationEvidenceFile toEvidenceFile(
1920
Verification verification,
20-
VerificationEvidenceType evidenceType
21+
VerificationEvidenceType evidenceType,
22+
VerificationFileStorage fileStorage
2123
) {
22-
byte[] evidenceData = resolveData(verification, evidenceType);
23-
if (evidenceData == null || evidenceData.length == 0) {
24+
String s3Key = resolveS3Key(verification, evidenceType);
25+
if (!StringUtils.hasText(s3Key)) {
2426
throw new BootSignalException(
2527
ErrorCode.VERIFICATION_EVIDENCE_INVALID,
2628
evidenceType.displayName() + " 정보가 올바르지 않습니다."
2729
);
2830
}
2931

32+
byte[] data = fileStorage.download(s3Key);
33+
3034
return new VerificationEvidenceFile(
3135
resolveFileName(verification, evidenceType),
3236
resolveContentType(verification, evidenceType),
33-
evidenceData
37+
data
3438
);
3539
}
3640

41+
private static String resolveS3Key(Verification verification, VerificationEvidenceType evidenceType) {
42+
return switch (evidenceType) {
43+
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryS3Key();
44+
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationS3Key();
45+
};
46+
}
47+
3748
private static String resolveFileName(Verification verification, VerificationEvidenceType evidenceType) {
3849
String fileName = switch (evidenceType) {
3950
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryFileName();
@@ -48,11 +59,4 @@ private static String resolveContentType(Verification verification, Verification
4859
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationContentType();
4960
};
5061
}
51-
52-
private static byte[] resolveData(Verification verification, VerificationEvidenceType evidenceType) {
53-
return switch (evidenceType) {
54-
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryData();
55-
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationData();
56-
};
57-
}
5862
}

0 commit comments

Comments
 (0)