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
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

//aws
implementation platform('software.amazon.awssdk:bom:2.20.0') // AWS SDK 버전 관리
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:sts'
implementation 'software.amazon.awssdk:core:2.30.20'
implementation 'software.amazon.awssdk:ec2'
}

tasks.named('test') {
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/es/princip/ringus/domain/mentee/Mentee.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package es.princip.ringus.domain.mentee;

import es.princip.ringus.domain.common.Education;
import es.princip.ringus.infra.storage.domain.Certificate;
import es.princip.ringus.infra.storage.domain.ProfileImage;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
Expand Down Expand Up @@ -30,6 +32,9 @@ public class Mentee {
@Column(name = "introduction", length = 500)
private String introduction;

@Embedded
private ProfileImage profileImage;

@Column(name = "member_id")
private Long memberId;

Expand All @@ -45,4 +50,11 @@ public Mentee(
this.introduction = introduction;
this.memberId = memberId;
}

/**
* 프로필 이미지 업데이트
*/
public void updateProfileImage(ProfileImage profileImage) {
this.profileImage = profileImage;
}
}
12 changes: 12 additions & 0 deletions src/main/java/es/princip/ringus/domain/mentor/Mentor.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import es.princip.ringus.domain.mentor.vo.Organization;
import es.princip.ringus.domain.mentor.vo.Portfolio;
import es.princip.ringus.domain.mentor.vo.Timezone;
import es.princip.ringus.infra.storage.domain.Certificate;
import es.princip.ringus.infra.storage.domain.ProfileImage;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
Expand Down Expand Up @@ -66,6 +68,9 @@ public class Mentor {
@Embedded
private Portfolio portfolio;

@Embedded
private ProfileImage profileImage;

@Column(name = "member_id")
private Long memberId;

Expand Down Expand Up @@ -93,4 +98,11 @@ public Mentor(
this.portfolio = portfolio;
this.memberId = memberId;
}

/**
* 프로필 이미지 업데이트
*/
public void updateProfileImage(ProfileImage profileImage) {
this.profileImage = profileImage;
}
}
40 changes: 40 additions & 0 deletions src/main/java/es/princip/ringus/infra/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package es.princip.ringus.infra.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Component
public class S3Config {
@Value("${aws.s3.region}")
private String region;

@Value("${aws.s3.access-key:}")
private String accessKey; // 액세스 키 (없으면 빈 값)

@Value("${aws.s3.secret-key:}")
private String secretKey; // 시크릿 키 (없으면 빈 값)

@Bean
public S3Client s3Client() {
// 로컬 환경에서 액세스 키와 시크릿 키가 설정되어 있으면 StaticCredentialsProvider 사용
if (!accessKey.isEmpty() && !secretKey.isEmpty()) {
AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
.build();
}

// EC2 환경에서는 DefaultCredentialsProvider 사용 (IAM 역할)
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package es.princip.ringus.infra.storage.api;

import es.princip.ringus.global.util.ApiResponseWrapper;
import es.princip.ringus.infra.storage.application.StorageService;
import es.princip.ringus.infra.storage.domain.CertificateType;
import es.princip.ringus.infra.storage.dto.CertificateUploadRequest;
import es.princip.ringus.infra.storage.dto.ProfileUploadRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/storage")
@RequiredArgsConstructor
public class StorageController {

private final StorageService storageService;

/**
* 멘티 증명서 업로드
*/
@PostMapping("/certificate/mentee")
public ResponseEntity<ApiResponseWrapper<Void>> uploadMenteeCertificate(
@ModelAttribute CertificateUploadRequest certificateUploadRequest
) {
String filePath = storageService.uploadMenteeCertificate(certificateUploadRequest);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, filePath));
}

/**
* 멘토 증명서 업로드
*/
@PostMapping("/certificate/mentor")
public ResponseEntity<ApiResponseWrapper<Void>> uploadMentorCertificate(
@ModelAttribute CertificateUploadRequest certificateUploadRequest
) {
String filePath = storageService.uploadMentorCertificate(certificateUploadRequest);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, filePath));
}

/**
* 프로필 이미지 업로드
*/
@PostMapping("/profile/image")
public ResponseEntity<ApiResponseWrapper<Void>> uploadProfileImage(
@ModelAttribute ProfileUploadRequest request
) {
String filePath = storageService.uploadProfileImage(request);
return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, filePath));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package es.princip.ringus.infra.storage.application;

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Client s3Client;

@Value("${aws.s3.bucket-name}")
private String bucketName;

/**
* S3에 파일 업로드
* @param file 업로드할 파일
* @param folderPath S3에 저장할 폴더 경로 (예: "profile-images/mentor", "certificates/mentee/ENROLLMENT" 등)
* @return 업로드된 파일의 S3 URL
*/
public String uploadFile(MultipartFile file, String folderPath) {
String fileName = UUID.randomUUID() + "_" + sanitizeFileName(file.getOriginalFilename());
String s3Key = folderPath + "/" + fileName;

try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.contentType(file.getContentType())
.build(),
RequestBody.fromInputStream(file.getInputStream(), file.getSize())
);
} catch (IOException e) {
throw new RuntimeException("파일 입력 스트림을 읽지 못했습니다.", e);
} catch (S3Exception e) {
throw new RuntimeException("S3에 파일 업로드에 실패했습니다.", e);
}

return getFileUrl(s3Key);
}

/**
* S3에 저장된 파일의 URL 반환
*/
private String getFileUrl(String s3Key) {
return "https://" + bucketName + ".s3.amazonaws.com/" + s3Key;
}

/**
* 파일 이름을 URL 인코딩
*/
private String sanitizeFileName(String originalFilename) {
try {
return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8.toString())
.replaceAll("\\+", "%20");
} catch (Exception e) {
throw new RuntimeException("파일 이름 인코딩에 실패했습니다.", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package es.princip.ringus.infra.storage.application;

import es.princip.ringus.domain.member.MemberType;
import es.princip.ringus.domain.mentee.Mentee;
import es.princip.ringus.domain.mentee.MenteeRepository;
import es.princip.ringus.domain.mentor.Mentor;
import es.princip.ringus.domain.mentor.MentorRepository;
import es.princip.ringus.infra.storage.domain.CertificateType;
import es.princip.ringus.infra.storage.domain.ProfileImage;
import es.princip.ringus.infra.storage.dto.CertificateUploadRequest;
import es.princip.ringus.infra.storage.dto.ProfileUploadRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class StorageService {

private final S3Service s3Service;
private final MenteeRepository menteeRepository;
private final MentorRepository mentorRepository;

@Transactional
public String uploadMenteeCertificate(CertificateUploadRequest request) {

String folderPath = buildCertificateFolderPath(request.certificateType(), false);

return s3Service.uploadFile(request.file(), folderPath);
}

/**
* 멘토 증명서 업로드
*/
@Transactional
public String uploadMentorCertificate(CertificateUploadRequest request) {

String folderPath = buildCertificateFolderPath(request.certificateType(), true);

return s3Service.uploadFile(request.file(), folderPath);
}

/**
* 프로필 이미지 업로드
*/
@Transactional
public String uploadProfileImage(ProfileUploadRequest request) {
String folderPath = buildProfileFolderPath(request.memberType());
String uploadedFilePath = s3Service.uploadFile(request.file(), folderPath);

ProfileImage profileImage = ProfileUploadRequest.toEntity(request, uploadedFilePath);

if(request.memberType() == MemberType.MENTEE) {
Mentee mentee = menteeRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("Mentee not found with id: " + request.userId()));
mentee.updateProfileImage(profileImage);
menteeRepository.save(mentee);
} else if(request.memberType() == MemberType.MENTOR) {
Mentor mentor = mentorRepository.findById(request.userId())
.orElseThrow(() -> new IllegalArgumentException("Mentor not found with id: " + request.userId()));
mentor.updateProfileImage(profileImage);
mentorRepository.save(mentor);
}
return uploadedFilePath;
}

// 증명서 폴더 경로 생성 (멘티용)
private String buildCertificateFolderPath(CertificateType certificateType, Boolean isMentor) {
if (isMentor) {
return "certificates/mentor/" + certificateType.name().toLowerCase();
}
return "certificates/mentee/" + certificateType.name().toLowerCase();
}

// 프로필 이미지 폴더 경로 생성
private String buildProfileFolderPath(MemberType userType) {
return "images/profile/" + userType.name().toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package es.princip.ringus.infra.storage.domain;

import jakarta.persistence.Embeddable;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Certificate extends File {

@Enumerated(EnumType.STRING)
private CertificateType certificateType;

@Builder
public Certificate(String fileName, String filePath, CertificateType certificateType) {
super(fileName, filePath);
this.certificateType = certificateType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package es.princip.ringus.infra.storage.domain;

public enum CertificateType {
ENROLLMENT, // 재학증명서
GRADUATION, // 졸업증명서
EMPLOYMENT, // 재직증명서
}
23 changes: 23 additions & 0 deletions src/main/java/es/princip/ringus/infra/storage/domain/File.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package es.princip.ringus.infra.storage.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class File {

@Column(nullable = false)
private String fileName; // 파일 이름 (S3 키)

@Column(nullable = false)
private String filePath; // S3 경로

protected File(String fileName, String filePath) {
this.fileName = fileName;
this.filePath = filePath;
}
}
Loading
Loading