Skip to content

Commit

Permalink
[WALWAL-122] 이미지 업로드 기능 (#58)
Browse files Browse the repository at this point in the history
* feat: Image 도메인 및 DTO 정의

* feat: aws 의존성 추가 및 Image Controller, Service 추가

* fix: image 로직 임시 커밋

* [WALWAL-148] Dev, Prod 환경 분리 (#56)

* feat: Environment 환경 분리

* test: dev profile swagger permitAll

* fix: fixtureMokey 수정

* refactor: dev 환경 배포 테스트

* refactor: dev 환경 배포 테스트

* fix: fixtureMokey 수정

* test: dev profile swagger permitAll

* refactor: dev 환경 배포 테스트

* fix: push branch develop으로 변경

* fix: profile 예외 메세지

* chore: s3 Config 추가

* feat: Member 이미지 업로드 기능

* fix: 이미지 업로드 로직 수정

* refactor: @dbscks97 피드백 반영

* refactor: @kwanok 피드백 반영

* fix: MissionCreateRequest test 코드
  • Loading branch information
char-yb authored Jul 28, 2024
1 parent 90c9324 commit 0103af0
Show file tree
Hide file tree
Showing 18 changed files with 385 additions and 7 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ dependencies {
implementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.security:spring-security-oauth2-client'

// AWS
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public class AppleClient {
private static final int APPLE_TOKEN_EXPIRE_MINUTES = 5;

// apple server에서 받아온 id_token
public AppleTokenResponse getAppleToken(AppleTokenRequest appleTokenRequest) {
private AppleTokenResponse getAppleToken(AppleTokenRequest appleTokenRequest) {
// Prepare form data
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", appleTokenRequest.client_id());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.depromeet.stonebed.domain.image.api;

import com.depromeet.stonebed.domain.image.application.ImageService;
import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageCreateRequest;
import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageUploadCompleteRequest;
import com.depromeet.stonebed.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.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "3. [이미지]", description = "이미지 관련 API입니다.")
@RestController
@RequestMapping("/images")
@RequiredArgsConstructor
public class ImageController {

private final ImageService imageService;

@Operation(
summary = "회원 프로필 이미지 Presigned URL 생성",
description = "회원 프로필 이미지 Presigned URL을 생성합니다.")
@PostMapping("/members/me/upload-url")
public PresignedUrlResponse memberProfilePresignedUrlCreate(
@Valid @RequestBody MemberProfileImageCreateRequest request) {
return imageService.createMemberProfilePresignedUrl(request);
}

@Operation(summary = "회원 프로필 이미지 업로드 완료", description = "회원 프로필 이미지 업로드 완료 업로드 상태를 변경합니다.")
@PostMapping("/members/me/upload-complete")
public ResponseEntity<Void> memberProfileUploadedV2(
@Valid @RequestBody MemberProfileImageUploadCompleteRequest request) {
imageService.uploadCompleteMemberProfile(request);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package com.depromeet.stonebed.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.stonebed.domain.image.dao.ImageRepository;
import com.depromeet.stonebed.domain.image.domain.Image;
import com.depromeet.stonebed.domain.image.domain.ImageFileExtension;
import com.depromeet.stonebed.domain.image.domain.ImageType;
import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageCreateRequest;
import com.depromeet.stonebed.domain.image.dto.request.MemberProfileImageUploadCompleteRequest;
import com.depromeet.stonebed.domain.image.dto.response.PresignedUrlResponse;
import com.depromeet.stonebed.domain.member.domain.Member;
import com.depromeet.stonebed.domain.member.domain.Profile;
import com.depromeet.stonebed.global.common.constants.UrlConstants;
import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import com.depromeet.stonebed.global.util.MemberUtil;
import com.depromeet.stonebed.global.util.SpringEnvironmentUtil;
import com.depromeet.stonebed.infra.properties.S3Properties;
import java.net.URL;
import java.util.Date;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class ImageService {

private final ImageRepository imageRepository;
private final AmazonS3 amazonS3;
private final MemberUtil memberUtil;
private final S3Properties s3Properties;
private final SpringEnvironmentUtil springEnvironmentUtil;

public PresignedUrlResponse createMemberProfilePresignedUrl(
MemberProfileImageCreateRequest request) {
final Member currentMember = memberUtil.getCurrentMember();
validateImageFileExtension(request.imageFileExtension());
String imageKey = generateUUID();
String fileName =
createFileName(
ImageType.MEMBER_PROFILE,
currentMember.getId(),
imageKey,
request.imageFileExtension());
GeneratePresignedUrlRequest presignedUrlRequest =
createPreSignedUrlRequest(
s3Properties.bucket(),
fileName,
request.imageFileExtension().getUploadExtension());

URL presignedUrl = amazonS3.generatePresignedUrl(presignedUrlRequest);
imageRepository.save(
Image.createImage(
ImageType.MEMBER_PROFILE,
currentMember.getId(),
imageKey,
request.imageFileExtension()));
return PresignedUrlResponse.from(presignedUrl.toString());
}

public void uploadCompleteMemberProfile(MemberProfileImageUploadCompleteRequest request) {
final Member currentMember = memberUtil.getCurrentMember();
validateImageFileExtension(request.imageFileExtension());

Image image =
findImage(
ImageType.MEMBER_PROFILE,
currentMember.getId(),
request.imageFileExtension());
String imageUrl =
createReadImageUrl(
ImageType.MEMBER_PROFILE,
currentMember.getId(),
image.getImageKey(),
request.imageFileExtension());
currentMember.updateProfile(Profile.createProfile(request.nickname(), imageUrl));
}

private void validateImageFileExtension(ImageFileExtension imageFileExtension) {
if (imageFileExtension == null) {
throw new CustomException(ErrorCode.IMAGE_FILE_EXTENSION_NOT_FOUND);
}
try {
ImageFileExtension.of(imageFileExtension.getUploadExtension());
} catch (IllegalArgumentException e) {
throw new CustomException(ErrorCode.INVALID_IMAGE_FILE_EXTENSION);
}
}

private Image findImage(
ImageType imageType, Long targetId, ImageFileExtension imageFileExtension) {
return imageRepository
.findTopByImageTypeAndTargetIdAndImageFileExtensionOrderByIdDesc(
imageType, targetId, imageFileExtension)
.orElseThrow(() -> new CustomException(ErrorCode.IMAGE_KEY_NOT_FOUND));
}

private String generateUUID() {
return UUID.randomUUID().toString();
}

private String createFileName(
ImageType imageType,
Long targetId,
String imageKey,
ImageFileExtension imageFileExtension) {
return String.format(
"%s/%s/%d/%s.%s",
springEnvironmentUtil.getCurrentProfile(),
imageType.getValue(),
targetId,
imageKey,
imageFileExtension.getUploadExtension());
}

private String createReadImageUrl(
ImageType imageType,
Long targetId,
String imageKey,
ImageFileExtension imageFileExtension) {
return String.format(
"%s/%s/%s/%d/%s.%s",
UrlConstants.IMAGE_DOMAIN_URL.getValue(),
springEnvironmentUtil.getCurrentProfile(),
imageType.getValue(),
targetId,
imageKey,
imageFileExtension.getUploadExtension());
}

private GeneratePresignedUrlRequest createPreSignedUrlRequest(
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() {
return new Date(System.currentTimeMillis() + 1000 * 60 * 30);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.depromeet.stonebed.domain.image.dao;

import com.depromeet.stonebed.domain.image.domain.Image;
import com.depromeet.stonebed.domain.image.domain.ImageFileExtension;
import com.depromeet.stonebed.domain.image.domain.ImageType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ImageRepository extends JpaRepository<Image, Long> {
Optional<Image> findTopByImageTypeAndTargetIdAndImageFileExtensionOrderByIdDesc(
ImageType imageType, Long targetId, ImageFileExtension imageFileExtension);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.depromeet.stonebed.domain.image.domain;

import com.depromeet.stonebed.domain.common.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Image extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "image_id")
private Long id;

@Enumerated(EnumType.STRING)
private ImageType imageType;

// targetId 예시: missionRecord와 같이 이미지를 가지는 대상의 id
private Long targetId;

@Column(length = 36)
private String imageKey;

@Enumerated(EnumType.STRING)
private ImageFileExtension imageFileExtension;

@Builder(access = AccessLevel.PRIVATE)
private Image(
Long id,
ImageType imageType,
Long targetId,
String imageKey,
ImageFileExtension imageFileExtension) {
this.id = id;
this.imageType = imageType;
this.targetId = targetId;
this.imageKey = imageKey;
this.imageFileExtension = imageFileExtension;
}

public static Image createImage(
ImageType imageType,
Long targetId,
String imageKey,
ImageFileExtension imageFileExtension) {
return Image.builder()
.imageType(imageType)
.targetId(targetId)
.imageKey(imageKey)
.imageFileExtension(imageFileExtension)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.depromeet.stonebed.domain.image.domain;

import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import java.util.Arrays;
import lombok.AllArgsConstructor;
import lombok.Getter;

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

private final String uploadExtension;

public static ImageFileExtension of(String uploadExtension) {
return Arrays.stream(values())
.filter(
imageFileExtension ->
imageFileExtension.uploadExtension.equals(uploadExtension))
.findFirst()
.orElseThrow(() -> new CustomException(ErrorCode.IMAGE_FILE_EXTENSION_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.depromeet.stonebed.domain.image.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

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

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

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

import com.depromeet.stonebed.domain.image.domain.ImageFileExtension;
import io.swagger.v3.oas.annotations.media.Schema;

public record MemberProfileImageUploadCompleteRequest(
@Schema(description = "이미지 파일의 확장자", defaultValue = "JPEG")
ImageFileExtension imageFileExtension,
@Schema(description = "닉네임", defaultValue = "수정닉네임") String nickname) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.stonebed.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 @@ -20,7 +20,7 @@ public class MissionController {

@PostMapping
public MissionCreateResponse createMission(
@RequestBody MissionCreateRequest missionCreateRequest) {
@Valid @RequestBody MissionCreateRequest missionCreateRequest) {
return missionService.createMission(missionCreateRequest);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import jakarta.validation.constraints.NotBlank;

public record MissionCreateRequest(
@Schema(description = "미션 ID", example = "1") @NotBlank Long id,
@Schema(description = "미션 제목", example = "산책하기")
@NotBlank(message = "Title cannot be blank")
String title) {}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum UrlConstants {
PROD_SERVER_URL("https://api.walwal.life"),
DEV_SERVER_URL("https://dev-api.walwal.life"),
LOCAL_SERVER_URL("http://localhost:8080"),
IMAGE_DOMAIN_URL("https://image.walwal.life"),
;

private final String value;
Expand Down
Loading

0 comments on commit 0103af0

Please sign in to comment.