Skip to content

Commit

Permalink
[MERGE/#120] 사진 업로드 용 API 구현
Browse files Browse the repository at this point in the history
[FEAT] 사진 업로드 용 API 구현
  • Loading branch information
seokbeom00 authored Jul 19, 2024
2 parents 5b1a308 + 6c12966 commit 0d3d560
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ public static Member create(
public void updateMember(
Boolean isSubscribed,
String nickname,
String image,
String phoneNumber,
String univName,
String field,
Expand All @@ -105,9 +104,6 @@ public void updateMember(
if (nickname != null) {
this.nickname = nickname;
}
if (image != null) {
this.image = image;
}
if (phoneNumber != null) {
this.phoneNumber = phoneNumber;
}
Expand All @@ -125,4 +121,8 @@ public void updateMember(
public void addSenior(Senior senior) {
this.senior = senior;
}

public void addProfile(String image) {
this.image = image;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,6 @@ public MemberJoinResponse patchMemberJoin(MemberJoinRequest memberJoinRequest) {
member.updateMember(
memberJoinRequest.isSubscribed(),
memberJoinRequest.nickname(),
memberJoinRequest.image(),
memberJoinRequest.phoneNumber(),
memberJoinRequest.univName(),
memberJoinRequest.field(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@ public record SeniorCardProfileResponse(
String field,
String position,
String detailPosition,
String level
String level,
String image
) {
public static SeniorCardProfileResponse of(
final String nickname,
final String company,
final String field,
final String position,
final String detailPosition,
final String level
final String level,
final String image
) {
return new SeniorCardProfileResponse(
nickname,
company,
field,
position,
detailPosition,
level
level,
image
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,12 @@ public class Senior extends BaseTimeEntity {
@Builder(access = AccessLevel.PRIVATE)
private Senior(
Member member,
String businessCard,
String detailPosition,
String company,
String position,
String level
) {
this.member = member;
this.businessCard = businessCard;
this.detailPosition = detailPosition;
this.company = company;
this.position = position;
Expand All @@ -82,15 +80,13 @@ private Senior(

public static Senior create(
Member member,
String businessCard,
String detailPosition,
String company,
String position,
String level
) {
return Senior.builder()
.member(member)
.businessCard(businessCard)
.detailPosition(detailPosition)
.company(company)
.position(position)
Expand All @@ -111,4 +107,10 @@ public void updateSenior(
this.story = story;
this.preferredTimeList = preferredTimeList;
}

public void addBusinessCard(
String businessCard
) {
this.businessCard = businessCard;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ public Senior createSenior(final MemberJoinRequest memberJoinRequest, Member mem

Senior senior = Senior.create(
member,
memberJoinRequest.businessCard(),
memberJoinRequest.detailPosition(),
memberJoinRequest.company(),
memberJoinRequest.position(),
Expand Down Expand Up @@ -89,6 +88,7 @@ public SeniorProfileResponse getSeniorProfile(final Long seniorId) {

@Transactional(readOnly = true)
public SeniorCardProfileResponse getSeniorCardProfile(final Long seniorId) {
Member member = memberRepository.findMemberByIdOrThrow(principalHandler.getUserIdFromPrincipal());
Senior senior = seniorRepository.findSeniorByIdOrThrow(seniorId);

return SeniorCardProfileResponse.of(
Expand All @@ -97,7 +97,8 @@ public SeniorCardProfileResponse getSeniorCardProfile(final Long seniorId) {
senior.getMember().getField(),
senior.getPosition(),
senior.getDetailPosition(),
senior.getLevel()
senior.getLevel(),
member.getImage()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package org.sopt.seonyakServer.global.common.external.s3;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.seonyakServer.global.common.external.s3.dto.PreSignedUrlResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/v1")
Expand All @@ -18,4 +22,19 @@ public ResponseEntity<PreSignedUrlResponse> getPreSignedUrl() {
return ResponseEntity.ok(s3Service.getUploadPreSignedUrl());
}

@PatchMapping("/profile-image")
public ResponseEntity<Void> uploadProfileImage(
@RequestParam("profileImage") @Valid MultipartFile profileImage
) {
s3Service.uploadProfile(profileImage);
return ResponseEntity.ok().build();
}

@PatchMapping("/businesscard-image")
public ResponseEntity<Void> uploadBusinessCardImage(
@RequestParam("businessCardImage") @Valid MultipartFile businessCardImage
) {
s3Service.uploadBusinessCard(businessCardImage);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,23 @@

import java.net.URL;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.sopt.seonyakServer.domain.member.model.Member;
import org.sopt.seonyakServer.domain.member.repository.MemberRepository;
import org.sopt.seonyakServer.domain.senior.model.Senior;
import org.sopt.seonyakServer.domain.senior.repository.SeniorRepository;
import org.sopt.seonyakServer.global.auth.PrincipalHandler;
import org.sopt.seonyakServer.global.common.external.s3.dto.PreSignedUrlResponse;
import org.sopt.seonyakServer.global.exception.enums.ErrorType;
import org.sopt.seonyakServer.global.exception.model.CustomException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
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.presigner.S3Presigner;
Expand All @@ -21,19 +31,32 @@ public class S3Service {
@Value("${aws-property.s3-bucket-name}")
private String bucketName;

@Value("${aws-property.s3-substring}")
private String s3Substring;

private final S3Client s3Client;
private final S3Presigner s3Presigner;

private final PrincipalHandler principalHandler;
private final MemberRepository memberRepository;
private final SeniorRepository seniorRepository;

// PreSigned URL 만료시간 60분
private static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 60L;
private final static String imagePath = "profiles/";
// 파일 확장자 제한 jpeg, png, jpg, webp
private static final List<String> IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg",
"image/webp");
// 단일 PUT 요청 파일 크기 제한 (5MB)
private static final long MAX_FILE_SIZE = 5 * 1024L * 1024L;
private final static String profilePath = "profiles/";
private final static String businessPath = "businessCard/";

public PreSignedUrlResponse getUploadPreSignedUrl() {
try {
// UUID 파일명 생성
String uuidFileName = UUID.randomUUID().toString() + ".jpg";
// 경로 + 파일 이름
String key = imagePath + uuidFileName;
String key = profilePath + uuidFileName;

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
Expand All @@ -55,4 +78,70 @@ public PreSignedUrlResponse getUploadPreSignedUrl() {
throw new CustomException(ErrorType.GET_UPLOAD_PRESIGNED_URL_ERROR);
}
}

@Transactional
public void uploadProfile(MultipartFile profileImage) {
validateExtension(profileImage);
validateFileSize(profileImage);

// UUID 파일명 생성
String uuidFileName = UUID.randomUUID().toString() + ".jpg";
// 경로 + 파일 이름
String key = profilePath + uuidFileName;
Member member = memberRepository.findMemberByIdOrThrow(principalHandler.getUserIdFromPrincipal());
member.addProfile("https://" + bucketName + s3Substring + key);
try {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(profileImage.getContentType())
.contentDisposition("inline")
.build();
RequestBody requestBody = RequestBody.fromBytes(profileImage.getBytes());
s3Client.putObject(request, requestBody);
} catch (Exception e) {
throw new CustomException(ErrorType.S3_UPLOAD_ERROR);
}
}

@Transactional
public void uploadBusinessCard(MultipartFile businessCardImage) {
validateExtension(businessCardImage);
validateFileSize(businessCardImage);

// UUID 파일명 생성
String uuidFileName = UUID.randomUUID().toString() + ".jpg";
// 경로 + 파일 이름
String key = businessPath + uuidFileName;
Member member = memberRepository.findMemberByIdOrThrow(principalHandler.getUserIdFromPrincipal());
Senior senior = seniorRepository.findSeniorByIdOrThrow(member.getSenior().getId());
senior.addBusinessCard("https://" + bucketName + s3Substring + key);
try {
PutObjectRequest request = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.contentType(businessCardImage.getContentType())
.contentDisposition("inline")
.build();
RequestBody requestBody = RequestBody.fromBytes(businessCardImage.getBytes());
s3Client.putObject(request, requestBody);
} catch (Exception e) {
throw new CustomException(ErrorType.S3_UPLOAD_ERROR);
}
}

// 파일 확장자 검증 (서버에 직접 이미지를 전송하는 경우)
private void validateExtension(MultipartFile image) {
String contentType = image.getContentType();
if (!IMAGE_EXTENSIONS.contains(contentType)) {
throw new CustomException(ErrorType.IMAGE_EXTENSION_ERROR);
}
}

// 파일 최대 크기 검증 (서버에 직접 이미지를 전송하는 경우)
private void validateFileSize(MultipartFile image) {
if (image.getSize() > MAX_FILE_SIZE) {
throw new CustomException(ErrorType.IMAGE_SIZE_ERROR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public enum ErrorType {
// S3 관련 오류
IMAGE_EXTENSION_ERROR(HttpStatus.BAD_REQUEST, "40051", "이미지 확장자는 jpg, png, webp만 가능합니다."),
IMAGE_SIZE_ERROR(HttpStatus.BAD_REQUEST, "40052", "이미지 사이즈는 5MB를 넘을 수 없습니다."),
S3_UPLOAD_ERROR(HttpStatus.BAD_REQUEST, "40053", "S3 이미지 업로드에 실패했습니다."),

// 인증 관련 오류
EMPTY_PRINCIPAL_ERROR(HttpStatus.BAD_REQUEST, "40076", "Principal 객체가 없습니다. (null)"),
Expand Down

0 comments on commit 0d3d560

Please sign in to comment.