Skip to content

Commit

Permalink
feat: PresignedUrl을 사용하여 이미지 업로드 기능 구현 (#106)
Browse files Browse the repository at this point in the history
* chore: endpoint 값 추가하여 config 수정

* feat: 미션 기록 이미지 Presigned URL 생성

* feat: 미션 기록 이미지 업로드 완료 처리

* chore: storage.yml에 endpoint 환경변수 추가

* test: Test 코드 추가

* style: spotless

* refactor: storage 저장 디렉토리 변경

* feat: MissionRecord 도메인 uploadStatus validation 예외처리

* refactor: 해당 미션이 접속한 유저와 일치하지 않을때 예외 발생추가

* style: spotless

* test: test코드 추가와 예외처리 변경

* style: spotless

* remove: 사용하지 않는 에러 제거

* style: ImageController Swagger Tag 넘버링

* refactor: ImageController RequestMapping 제거

* refactor: Transactional 클래스레벨로 추출

* style: spotless

* test: ImageController RequestMapping 제거 후 테스트코드 수정

* remove: .gitkeep 삭제

* refactor: 메소드 네이밍 변경

* refactor: MISSION_RECORD_USER_MISMATCH Service 레이어로 추출

* style: conflict 해결

* style: spotless
  • Loading branch information
kdomo authored Jan 10, 2024
1 parent bc58d55 commit 11c0153
Show file tree
Hide file tree
Showing 16 changed files with 787 additions and 15 deletions.
36 changes: 36 additions & 0 deletions src/main/java/com/depromeet/domain/image/api/ImageController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.depromeet.domain.image.api;

import com.depromeet.domain.image.application.ImageService;
import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest;
import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest;
import com.depromeet.domain.image.dto.response.PresignedUrlResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "4. [이미지]", description = "이미지 관련 API입니다.")
@RestController
@RequiredArgsConstructor
public class ImageController {
private final ImageService imageService;

@Operation(
summary = "미션 기록 이미지 Presigned URL 생성",
description = "미션 기록 이미지 Presigned URL를 생성합니다.")
@PostMapping("/records/upload-url")
public PresignedUrlResponse missionRecordPresignedUrlCreate(
@Valid @RequestBody MissionRecordImageCreateRequest request) {
return imageService.createMissionRecordPresignedUrl(request);
}

@Operation(summary = "미션 기록 이미지 업로드 완료처리", description = "미션 기록 이미지 업로드 완료 시 호출하시면 됩니다.")
@PostMapping("/records/upload-complete")
public void missionRecordUploaded(
@Valid @RequestBody MissionRecordImageUploadCompleteRequest request) {
imageService.uploadCompleteMissionRecord(request);
}
}
129 changes: 129 additions & 0 deletions src/main/java/com/depromeet/domain/image/application/ImageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.depromeet.domain.image.application;

import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.Headers;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.depromeet.domain.image.domain.ImageFileExtension;
import com.depromeet.domain.image.domain.ImageType;
import com.depromeet.domain.image.dto.request.MissionRecordImageCreateRequest;
import com.depromeet.domain.image.dto.request.MissionRecordImageUploadCompleteRequest;
import com.depromeet.domain.image.dto.response.PresignedUrlResponse;
import com.depromeet.domain.member.domain.Member;
import com.depromeet.domain.mission.domain.Mission;
import com.depromeet.domain.missionRecord.dao.MissionRecordRepository;
import com.depromeet.domain.missionRecord.domain.MissionRecord;
import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import com.depromeet.global.util.MemberUtil;
import com.depromeet.global.util.SpringEnvironmentUtil;
import com.depromeet.infra.config.storage.StorageProperties;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class ImageService {
private final MemberUtil memberUtil;
private final SpringEnvironmentUtil springEnvironmentUtil;
private final StorageProperties storageProperties;
private final AmazonS3 amazonS3;
private final MissionRecordRepository missionRecordRepository;

public PresignedUrlResponse createMissionRecordPresignedUrl(
MissionRecordImageCreateRequest request) {
final Member currentMember = memberUtil.getCurrentMember();

MissionRecord missionRecord = findMissionRecordById(request.missionRecordId());

Mission mission = missionRecord.getMission();
validateMissionRecordUserMismatch(mission, currentMember);

String fileName =
createFileName(
ImageType.MISSION_RECORD,
request.missionRecordId(),
request.imageFileExtension());
GeneratePresignedUrlRequest generatePresignedUrlRequest =
createGeneratePreSignedUrlRequest(
storageProperties.bucket(),
fileName,
request.imageFileExtension().getUploadExtension());

String presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString();

missionRecord.updateUploadStatusPending();
return PresignedUrlResponse.from(presignedUrl);
}

private MissionRecord findMissionRecordById(Long request) {
return missionRecordRepository
.findById(request)
.orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND));
}

public void uploadCompleteMissionRecord(MissionRecordImageUploadCompleteRequest request) {
final Member currentMember = memberUtil.getCurrentMember();
MissionRecord missionRecord = findMissionRecordById(request.missionRecordId());

Mission mission = missionRecord.getMission();
validateMissionRecordUserMismatch(mission, currentMember);

String imageUrl =
storageProperties.endpoint()
+ "/"
+ storageProperties.bucket()
+ "/"
+ springEnvironmentUtil.getCurrentProfile()
+ "/"
+ ImageType.MISSION_RECORD.getValue()
+ "/"
+ request.missionRecordId()
+ "/image."
+ request.imageFileExtension().getUploadExtension();
missionRecord.updateUploadStatusComplete(request.remark(), imageUrl);
}

private String createFileName(
ImageType imageType, Long targetId, ImageFileExtension imageFileExtension) {
return springEnvironmentUtil.getCurrentProfile()
+ "/"
+ imageType.getValue()
+ "/"
+ targetId
+ "/image."
+ imageFileExtension.getUploadExtension();
}

private GeneratePresignedUrlRequest createGeneratePreSignedUrlRequest(
String bucket, String fileName, String fileExtension) {
GeneratePresignedUrlRequest generatePresignedUrlRequest =
new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT)
.withKey(fileName)
.withContentType("image/" + fileExtension)
.withExpiration(getPreSignedUrlExpiration());

generatePresignedUrlRequest.addRequestParameter(
Headers.S3_CANNED_ACL, CannedAccessControlList.PublicRead.toString());

return generatePresignedUrlRequest;
}

private Date getPreSignedUrlExpiration() {
Date expiration = new Date();
var expTimeMillis = expiration.getTime();
expTimeMillis += 1000 * 60 * 30;
expiration.setTime(expTimeMillis);
return expiration;
}

private void validateMissionRecordUserMismatch(Mission mission, Member member) {
if (!mission.getMember().getId().equals(member.getId())) {
throw new CustomException(ErrorCode.MISSION_RECORD_USER_MISMATCH);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.depromeet.domain.image.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ImageFileExtension {
JPEG("jpeg"),
JPG("jpg"),
PNG("png"),
;

private final String uploadExtension;
}
14 changes: 14 additions & 0 deletions src/main/java/com/depromeet/domain/image/domain/ImageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.depromeet.domain.image.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ImageType {
MISSION_RECORD("mission_record"),
MEMBER_PROFILE("member_profile"),
MEMBER_BACKGROUND("member_background"),
;
private final String value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.depromeet.domain.image.dto.request;

import com.depromeet.domain.image.domain.ImageFileExtension;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

public record MissionRecordImageCreateRequest(
@NotNull(message = "미션 기록 ID는 비워둘 수 없습니다.")
@Schema(description = "미션 기록 ID", defaultValue = "1")
Long missionRecordId,
@NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.")
@Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG")
ImageFileExtension imageFileExtension) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.depromeet.domain.image.dto.request;

import com.depromeet.domain.image.domain.ImageFileExtension;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record MissionRecordImageUploadCompleteRequest(
@NotNull(message = "미션 기록 ID는 비워둘 수 없습니다.")
@Schema(description = "미션 기록 ID", defaultValue = "1")
Long missionRecordId,
@NotNull(message = "이미지 파일의 확장자는 비워둘 수 없습니다.")
@Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG")
ImageFileExtension imageFileExtension,
@Size(max = 200, message = "미션 일지는 20자 이하까지만 입력 가능합니다.") String remark) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.domain.image.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record PresignedUrlResponse(@Schema(description = "Presigned URL") String presignedUrl) {
public static PresignedUrlResponse from(String presignedUrl) {
return new PresignedUrlResponse(presignedUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.depromeet.domain.common.model.BaseTimeEntity;
import com.depromeet.domain.mission.domain.Mission;
import com.depromeet.global.error.exception.CustomException;
import com.depromeet.global.error.exception.ErrorCode;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down Expand Up @@ -77,4 +79,20 @@ public static MissionRecord createMissionRecord(
.mission(mission)
.build();
}

public void updateUploadStatusPending() {
if (this.uploadStatus != ImageUploadStatus.NONE) {
throw new CustomException(ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_NONE);
}
this.uploadStatus = ImageUploadStatus.PENDING;
}

public void updateUploadStatusComplete(String remark, String imageUrl) {
if (this.uploadStatus != ImageUploadStatus.PENDING) {
throw new CustomException(ErrorCode.MISSION_RECORD_UPLOAD_STATUS_IS_NOT_PENDING);
}
this.uploadStatus = ImageUploadStatus.COMPLETE;
this.remark = remark;
this.imageUrl = imageUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class MissionRecordService {
private final MissionRecordRepository missionRecordRepository;

public Long createMissionRecord(MissionRecordCreateRequest request) {
final Mission mission = findMission(request);
final Mission mission = findMissionById(request.missionId());
final Member member = memberUtil.getCurrentMember();

Duration duration =
Expand All @@ -42,6 +42,12 @@ public Long createMissionRecord(MissionRecordCreateRequest request) {
return missionRecordRepository.save(missionRecord).getId();
}

private Mission findMissionById(Long missionId) {
return missionRepository
.findById(missionId)
.orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND));
}

@Transactional(readOnly = true)
public MissionRecordFindOneResponse findOneMissionRecord(Long recordId) {
MissionRecord missionRecord =
Expand All @@ -59,21 +65,15 @@ public List<MissionRecordFindResponse> findAllMissionRecord(
return missionRecords.stream().map(MissionRecordFindResponse::from).toList();
}

private Mission findMission(MissionRecordCreateRequest request) {
return missionRepository
.findById(request.missionId())
.orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND));
private void validateMissionRecordDuration(Duration duration) {
if (duration.getSeconds() > 3600L) {
throw new CustomException(ErrorCode.MISSION_RECORD_DURATION_OVERBALANCE);
}
}

private void validateMissionRecordUserMismatch(Mission mission, Member member) {
if (!member.getId().equals(mission.getMember().getId())) {
if (!mission.getMember().getId().equals(member.getId())) {
throw new CustomException(ErrorCode.MISSION_RECORD_USER_MISMATCH);
}
}

private void validateMissionRecordDuration(Duration duration) {
if (duration.getSeconds() > 3600L) {
throw new CustomException(ErrorCode.MISSION_RECORD_DURATION_OVERBALANCE);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public enum ErrorCode {
MISSION_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 미션 기록을 찾을 수 없습니다."),
MISSION_RECORD_USER_MISMATCH(HttpStatus.FORBIDDEN, "미션을 생성한 유저와 로그인된 계정이 일치하지 않습니다"),
MISSION_RECORD_DURATION_OVERBALANCE(HttpStatus.BAD_REQUEST, "미션 참여 시간이 지정 된 시간보다 초과하였습니다"),
MISSION_RECORD_UPLOAD_STATUS_IS_NOT_NONE(
HttpStatus.BAD_REQUEST, "미션 기록의 이미지 업로드 상태가 NONE이 아닙니다."),
MISSION_RECORD_UPLOAD_STATUS_IS_NOT_PENDING(
HttpStatus.BAD_REQUEST, "미션 기록의 이미지 업로드 상태가 PENDING이 아닙니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import lombok.RequiredArgsConstructor;
Expand All @@ -19,9 +20,13 @@ public AmazonS3 amazonS3() {
AWSCredentials credentials =
new BasicAWSCredentials(
storageProperties.accessKey(), storageProperties.secretKey());
AwsClientBuilder.EndpointConfiguration endpointConfiguration =
new AwsClientBuilder.EndpointConfiguration(
storageProperties.endpoint(), storageProperties.region());

return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(endpointConfiguration)
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(storageProperties.region())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "storage")
public record StorageProperties(String accessKey, String secretKey, String region, String bucket) {}
public record StorageProperties(
String accessKey, String secretKey, String region, String bucket, String endpoint) {}
3 changes: 2 additions & 1 deletion src/main/resources/application-storage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ spring:
storage:
accessKey: ${STORAGE_ACCESS_KEY:}
secretKey: ${STORAGE_SECRET_KEY:}
region: ${STORAGE_REGION:}
bucket: ${STORAGE_BUCKET:}
region: ${STORAGE_REGION:}
endpoint: ${STORAGE_ENDPOINT:https://kr.object.ncloudstorage.com}
Loading

0 comments on commit 11c0153

Please sign in to comment.