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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.ject.studytrip.image.application.service;

import com.ject.studytrip.global.config.properties.CdnProperties;
import com.ject.studytrip.global.exception.CustomException;
import com.ject.studytrip.global.util.FilenameUtil;
import com.ject.studytrip.image.application.dto.PresignedImageInfo;
import com.ject.studytrip.image.domain.constants.ImageConstants;
import com.ject.studytrip.image.domain.factory.ImageKeyFactory;
import com.ject.studytrip.image.domain.policy.ImagePolicy;
import com.ject.studytrip.image.domain.util.ImageUrlUtil;
import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo;
import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider;
import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider;
Expand All @@ -20,6 +22,8 @@ public class ImageService {
private final S3ImageStorageProvider s3Provider;
private final TikaImageProbeProvider tikaProvider;

private final CdnProperties cdnProps;

// Presigned URL 발급
public PresignedImageInfo presign(String keyPrefix, String id, String originFilename) {
// 키 prefix 검증
Expand Down Expand Up @@ -57,15 +61,20 @@ public String confirm(String tmpKey) {
// MIME 추출 및 판별, 검증 실패 시 이미지 삭제
validateMimeWithCleanup(tmpKey, head.contentLength());

// 임시 -> 최종 이미지 복사 및 반환
// 임시 -> 최종 이미지 복사 및 경로 반환
return moveToFinalLocation(tmpKey);
}

// 업로드 취소, 업로드된 이미지 삭제
// 업로드 취소
public void cancel(List<String> uploadedKeys) {
s3Provider.deleteByKeys(uploadedKeys);
}

// 이미지 삭제
public void cleanup(String imageUrl) {
ImageUrlUtil.extractKey(cdnProps.domain(), imageUrl).ifPresent(s3Provider::deleteByKey);
}

// 이미지 사이즈 검증, 실패 시 삭제
private void validateSizeWithCleanup(String tmpKey, long contentLength) {
try {
Expand All @@ -88,12 +97,13 @@ private void validateMimeWithCleanup(String tmpKey, long len) {
}
}

// 최종 경로에 이미지 복사
// 최종 경로에 이미지 복사 및 반환
private String moveToFinalLocation(String tmpKey) {
String finalKey = ImageKeyFactory.toFinalKey(tmpKey);
ImagePolicy.validateKey(finalKey);
s3Provider.copyByKey(tmpKey, finalKey);
return finalKey;

return ImageUrlUtil.build(cdnProps.domain(), finalKey);
}

// 삭제 및 예외 처리
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.ject.studytrip.image.domain.util;

import static org.springframework.util.StringUtils.hasText;

import java.net.URI;
import java.util.Optional;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class ImageUrlUtil {
// 이미지 최종 경로 생성
public static String build(String baseUrl, String key) {
if (!hasText(baseUrl) || !hasText(key)) {
return null;
}

String normalizedDomain =
baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;

return normalizedDomain + "/" + key.trim();
}

// 이미지 최종 경로에서 이미지 키 추출
public static Optional<String> extractKey(String baseUrl, String url) {
if (url == null || url.isBlank()) {
return Optional.empty();
}

if (baseUrl == null || baseUrl.isBlank()) {
return Optional.empty();
}

// URI 파싱
URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
return Optional.empty();
}

String host = uri.getHost();
if (host == null || !host.equals(extractHostName(baseUrl))) {
return Optional.empty();
}

String path = uri.getPath();
if (path == null || path.length() <= 1) {
return Optional.empty();
}

// 맨 앞의 '/' 제거
String key = path.startsWith("/") ? path.substring(1) : path;
return key.isBlank() ? Optional.empty() : Optional.of(key);
}

// 경로에서 Host 추출
private static String extractHostName(String baseUrl) {
if (baseUrl.startsWith("https://")) {
return baseUrl.substring(8).replaceAll("/$", "");
}
if (baseUrl.startsWith("http://")) {
return baseUrl.substring(7).replaceAll("/$", "");
}
return baseUrl.replaceAll("/$", "");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ject.studytrip.member.application.dto;

public record PresignedProfileImageInfo(Long memberId, String tmpKey, String presignedUrl) {
public static PresignedProfileImageInfo of(Long memberId, String tmpKey, String presignedUrl) {
return new PresignedProfileImageInfo(memberId, tmpKey, presignedUrl);
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
package com.ject.studytrip.member.application.facade;

import com.ject.studytrip.image.application.dto.PresignedImageInfo;
import com.ject.studytrip.image.application.service.ImageService;
import com.ject.studytrip.member.application.dto.MemberDetail;
import com.ject.studytrip.member.application.dto.MemberInfo;
import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo;
import com.ject.studytrip.member.application.service.MemberService;
import com.ject.studytrip.member.domain.model.Member;
import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest;
import com.ject.studytrip.studylog.application.service.StudyLogService;
import com.ject.studytrip.trip.application.dto.TripCount;
import com.ject.studytrip.trip.application.service.TripService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class MemberFacade {
private static final String MEMBER_PROFILE_IMAGE_KEY_PREFIX = "members";

private final MemberService memberService;
private final TripService tripService;
private final StudyLogService studyLogService;
private final ImageService imageService;

public void updateNicknameAndCategoryIfPresent(Long memberId, UpdateMemberRequest request) {
Member member = memberService.getActiveMemberById(memberId);
Expand All @@ -28,6 +37,7 @@ public void deleteMember(Long memberId) {
Member member = memberService.getActiveMemberById(memberId);

memberService.deleteMember(member);
imageService.cleanup(member.getProfileImage());
}

public MemberDetail getMemberDetail(Long memberId) {
Expand All @@ -39,4 +49,29 @@ public MemberDetail getMemberDetail(Long memberId) {

return MemberDetail.from(memberInfo, tripCount, studyLogCount);
}

@Transactional(readOnly = true)
public PresignedProfileImageInfo issuePresignedUrl(
Long memberId, PresignProfileImageRequest request) {
Member member = memberService.getActiveMemberById(memberId);
PresignedImageInfo info =
imageService.presign(
MEMBER_PROFILE_IMAGE_KEY_PREFIX,
member.getId().toString(),
request.originFilename());

return PresignedProfileImageInfo.of(member.getId(), info.tmpKey(), info.presignedUrl());
}

@Transactional
public void confirmImage(Long memberId, ConfirmProfileImageRequest request) {
Member member = memberService.getMember(memberId);
String finalKey = imageService.confirm(request.tmpKey());

// 기존 이미지 삭제 (이미지가 존재하지 않아도 예외발생 X)
imageService.cleanup(member.getProfileImage());

// 새로운 이미지 업데이트
memberService.updateProfileImage(member, finalKey);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public void updateNicknameAndCategoryIfPresent(Member member, UpdateMemberReques
member.update(request.nickname(), memberCategory);
}

public void updateProfileImage(Member member, String profileImage) {
MemberPolicy.validateNotDeleted(member);
member.updateProfileImage(profileImage);
}

@Transactional
public void deleteMember(Member member) {
member.updateDeletedAt();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ public static Member of(
.build();
}

// 프로필 이미지 수정 로직 추가 예정
public void update(String nickname, MemberCategory category) {
if (hasText(nickname) && !nickname.equals(this.nickname)) { // 다른 경우에만 닉네임 수정
this.nickname = nickname;
Expand All @@ -68,6 +67,10 @@ public void update(String nickname, MemberCategory category) {
}
}

public void updateProfileImage(String profileImage) {
if (hasText(profileImage)) this.profileImage = profileImage;
}

public void updateDeletedAt() {
this.deletedAt = LocalDateTime.now();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@

import com.ject.studytrip.global.common.response.StandardResponse;
import com.ject.studytrip.member.application.dto.MemberDetail;
import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo;
import com.ject.studytrip.member.application.facade.MemberFacade;
import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest;
import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest;
import com.ject.studytrip.member.presentation.dto.response.LoadMemberDetailResponse;
import com.ject.studytrip.member.presentation.dto.response.PresignProfileImageResponse;
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.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -56,4 +61,78 @@ public ResponseEntity<StandardResponse> loadMemberDetail(
result.tripCount(),
result.studyLogCount())));
}

@Operation(
summary = "멤버 프로필 이미지 업로드용 Presigned URL 발급",
description =
"""
멤버 프로필 이미지를 S3 Storage에 업로드하기 위한 Presigned URL을 발급합니다.

[흐름]
1) 멤버 수정 화면에서 수정하기 버튼을 클릭합니다.
2) 이때 만약 프로필 이미지를 변경했다면 해당 파일이름과 함께 본 API를 호출합니다.(예: abc.jpeg 형태)
2-1) 프로필 이미지를 변경하지 않았으면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리를 수정했을 경우)
3) 서버는 업로드에 사용할 Presigned PUT URL과 임시 키(tmpKey)를 반환합니다.
4) 클라이언트는 반환된 Presigned URL로 이미지를 S3에 업로드합니다.
5) 업로드가 정상 완료되면 즉시 프로필 이미지 Confirm API를 호출하여 이미지를 검증 및 확정하고 멤버 프로필에 적용합니다.
6) 이후 다른 수정 사항이 있다면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리도 수정했을 경우)

[주의]
- Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다.
- 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다.
""")
@PostMapping("/profile-images/presigned")
public ResponseEntity<StandardResponse> presigned(
@AuthenticationPrincipal String memberId,
@RequestBody @Valid PresignProfileImageRequest request) {
PresignedProfileImageInfo info =
memberFacade.issuePresignedUrl(Long.valueOf(memberId), request);
return ResponseEntity.ok()
.body(
StandardResponse.success(
HttpStatus.OK.value(),
PresignProfileImageResponse.of(
info.memberId(), info.tmpKey(), info.presignedUrl())));
}

@Operation(
summary = "업로드된 멤버 프로필 이미지 검증/확정",
description =
"""
Presigned URL을 통해 S3에 업로드된 프로필 이미지를 서버에서 검증하고 확정(Confirm)합니다.

[흐름]
1) 클라이언트는 발급받은 Presigned PUT URL로 이미지를 업로드합니다.
2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다.
임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다.
(예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/members/1/abc.jpg -> tmp/members/1/abc.jpg)

3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 회원 프로필 이미지 정보를 갱신합니다.
만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만,
유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야합니다.

S3 Storage 기술 자체 예외 예시
{
"status": 502 (BAD_GATEWAY),
"message": "Storage 서버 에러가 발생했습니다."
}

이미지 도메인 정책 위반 예외 예시
{
"status": 400 (BAD_REQUEST),
"message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등
}

[주의]
- 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다.
- 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다.
- 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다.
""")
@PostMapping("/profile-images/confirm")
public ResponseEntity<StandardResponse> confirm(
@AuthenticationPrincipal String memberId,
@RequestBody @Valid ConfirmProfileImageRequest request) {
memberFacade.confirmImage(Long.valueOf(memberId), request);
return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ject.studytrip.member.presentation.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;

public record ConfirmProfileImageRequest(
@Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.")
String tmpKey) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ject.studytrip.member.presentation.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;

public record PresignProfileImageRequest(
@Schema(description = "업로드할 원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.")
String originFilename) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.ject.studytrip.member.presentation.dto.response;

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

public record PresignProfileImageResponse(
@Schema(description = "멤버 ID") Long memberId,
@Schema(description = "멤버 프로필 이미지 임시키") String tmpKey,
@Schema(description = "멤버 프로필 이미지 업로드용 Presigned URL") String presignedUrl) {
public static PresignProfileImageResponse of(
Long memberId, String tmpKey, String presignedUrl) {
return new PresignProfileImageResponse(memberId, tmpKey, presignedUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ject.studytrip.studylog.application.dto;

public record PresignedStudyLogImageInfo(Long studyLogId, String tmpKey, String presignedUrl) {
public static PresignedStudyLogImageInfo of(
Long studyLogId, String tmpKey, String presignedUrl) {
return new PresignedStudyLogImageInfo(studyLogId, tmpKey, presignedUrl);
}
}
Loading