Skip to content

Commit

Permalink
feat: ✨ 사용자 프로필 이미지 등록 요청 API 구현 (#105)
Browse files Browse the repository at this point in the history
* feat: 사용자 프로필 이미지 등록 요청 dto 정의

* feat: 사용자 프로필 이미지 등록 요청 api 설계

* feat: user entity에 profile-image-url 수정 로직 추가

* feat: 사용자 프로필 이미지 등록을 위한 usecase 정의

* feat: s3 파일 존재 여부 반환 로직 구현

* feat: storage 저장 실패 시 에러코드 정의

* feat: s3 파일 복사 로직 구현

* feat: 사용자 프로필 이미지 저장 api 구현

* feat: 사용자 프로필 이미지 원본 저장 시 storage-class 적용

* fix: 이미지 리사이징을 위한 storage-class 수정

* docs: 프로필 이미지 등록 swagger 응답 케이스 추가

* test: 사용자 프로필 이미지 등록 api 테스트 코드 작성

* test: user-account-usecase에 aws-s3-provider mockbean 적용

* fix: 프로필 이미지 등록 메서드 put으로 변경

* docs: 프로필 이미지 등록 성공 시 예시 응답 제거

* docs: 프로필 이미지 등록 swagger parameter 제거

* fix: request dto validate 어노테이션 추가 및 tab-character 제거

* rename: s3 파일 존재 여부 메서드명 수정

* fix: 프로필 이미지 dto에 regex 패턴 검증 로직 추가

* test: 테스트 케이스 이미지 경로 수정

* fix: 프로필 이미지 등록 요청 dto 정적 팩토리 메서드 삭제

* refactor: 사용자 프로필 이미지 경로 prefix 환경변수 처리

* refactor: s3 object-key regex 상수로 분리

* feat: s3 object-key regex 클래스 분리 및 정적 변수로 선언
  • Loading branch information
jinlee1703 authored Jun 5, 2024
1 parent 00ac66b commit 35710d0
Show file tree
Hide file tree
Showing 16 changed files with 954 additions and 648 deletions.
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
package kr.co.pennyway.api.apis.storage.dto;

import java.net.URI;

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

import java.net.URI;

public class PresignedUrlDto {
@Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO")
public record Req(
@Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE")
@NotBlank(message = "이미지 종류는 필수입니다.")
String type,
@Schema(description = "파일 확장자", example = "jpg/png/jpeg")
@NotBlank(message = "파일 확장자는 필수입니다.")
String ext,
@Schema(description = "사용자 ID", example = "1")
String userId,
@Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678")
String chatroomId
) {
}
@Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 요청 DTO", description = "S3에 이미지를 저장하기 위한 Presigned URL을 발급 요청을 위한 DTO")
public record Req(
@Schema(description = "이미지 종류", example = "PROFILE/FEED/CHATROOM_PROFILE/CHAT/CHAT_PROFILE")
@NotBlank(message = "이미지 종류는 필수입니다.")
String type,
@Schema(description = "파일 확장자", example = "jpg/png/jpeg")
@NotBlank(message = "파일 확장자는 필수입니다.")
String ext,
@Schema(description = "사용자 ID", example = "1")
String userId,
@Schema(description = "채팅방 ID", example = "12345678-1234-5678-1234-567812345678")
String chatroomId
) {
}

@Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 응답 DTO")
public record Res(
@Schema(description = "Presigned URL")
URI presignedUrl
) {
/**
* Presigned URL 발급 응답 객체 생성
*
* @param presignedUrl String : Presigned URL
*/
public static Res of(URI presignedUrl) {
return new Res(presignedUrl);
}
}
@Schema(title = "S3 이미지 저장을 위한 Presigned URL 발급 응답 DTO")
public record Res(
@Schema(description = "Presigned URL")
URI presignedUrl
) {
/**
* Presigned URL 발급 응답 객체 생성
*
* @param presignedUrl String : Presigned URL
*/
public static Res of(URI presignedUrl) {
return new Res(presignedUrl);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.co.pennyway.api.apis.storage.service;

import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.net.URI;

@Slf4j
@Service
@RequiredArgsConstructor
public class StorageService {
private final AwsS3Provider awsS3Provider;

public URI getPresignedUrl(String type, String ext, String userId, String chatroomId) {
return awsS3Provider.generatedPresignedUrl(type, ext, userId, chatroomId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ public interface UserAccountApi {
""")
}))
})
ResponseEntity<?> postPasswordVerification(@RequestBody @Validated UserProfileUpdateDto.PasswordVerificationReq request, @AuthenticationPrincipal SecurityUserDetails user);
ResponseEntity<?> postPasswordVerification(@RequestBody @Validated UserProfileUpdateDto.PasswordVerificationReq request,
@AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "사용자 비밀번호 변경")
@ApiResponses({
Expand Down Expand Up @@ -199,4 +200,25 @@ public interface UserAccountApi {

@Operation(summary = "사용자 계정 삭제", description = "사용자 본인의 계정을 삭제합니다. 채팅방 방장이면 삭제가 안 되는 시나리오는 고려하지 않고 있습니다.")
ResponseEntity<?> deleteAccount(@AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "사용자 프로필 사진 등록", description = "사용자의 프로필 사진을 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "프로필 사진 URL이 유효하지 않은 경우", value = """
{
"code": "4000",
"message": "프로필 이미지 URL이 유효하지 않습니다."
}
""")
})),
@ApiResponse(responseCode = "404", content = @Content(mediaType = "application/json", examples = {
@ExampleObject(name = "프로필 사진 URL이 존재하지 않는 경우", value = """
{
"code": "4040",
"message": "프로필 이미지 URL이 존재하지 않습니다."
}
""")
}))
})
ResponseEntity<?> postProfileImage(@RequestBody @Validated UserProfileUpdateDto.ProfileImageReq request, @AuthenticationPrincipal SecurityUserDetails user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,12 @@ public ResponseEntity<?> deleteAccount(@AuthenticationPrincipal SecurityUserDeta
userAccountUseCase.deleteAccount(user.getUserId());
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Override
@PutMapping("/profile-image")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> postProfileImage(@Validated UserProfileUpdateDto.ProfileImageReq request, SecurityUserDetails user) {
userAccountUseCase.updateProfileImage(user.getUserId(), request);
return ResponseEntity.ok(SuccessResponse.noContent());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,13 @@ public static NotifySettingUpdateReq of(NotifySetting.NotifyType type, Boolean f
};
}
}

@Schema(title = "프로필 이미지 등록 요청 DTO")
public record ProfileImageReq(
@Schema(description = "프로필 이미지 URL", example = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg")
@Pattern(regexp = "^delete/.*$", message = "URL은 'delete/'로 시작해야 합니다.")
@NotBlank(message = "프로필 이미지 URL을 입력해주세요")
String profileImageUrl
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider;
import kr.co.pennyway.infra.client.aws.s3.ObjectKeyType;
import kr.co.pennyway.infra.common.exception.StorageErrorCode;
import kr.co.pennyway.infra.common.exception.StorageException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -15,6 +19,7 @@
@RequiredArgsConstructor
public class UserProfileUpdateService {
private final PasswordEncoderHelper passwordEncoderHelper;
private final AwsS3Provider awsS3Provider;

@Transactional
public void updateName(User user, String newName) {
Expand All @@ -36,6 +41,22 @@ public void updatePassword(User user, String oldPassword, String newPassword) {
user.updatePassword(passwordEncoderHelper.encodePassword(newPassword));
}

@Transactional
public void updateProfileImage(User user, String profileImageUrl) {
// Profile Image 존재 여부 확인
if (!awsS3Provider.isObjectExist(profileImageUrl)) {
log.info("프로필 이미지 URL이 유효하지 않습니다.");
throw new StorageException(StorageErrorCode.NOT_FOUND);
}

// Profile Image 원본 저장
awsS3Provider.copyObject(ObjectKeyType.PROFILE, profileImageUrl);

// Profile Image URL 업데이트
String originKey = ObjectKeyType.PROFILE.convertDeleteKeyToOriginKey(profileImageUrl);
user.updateProfileImageUrl(awsS3Provider.getObjectPrefix() + originKey);
}

@Transactional
public void updateNotifySetting(User user, NotifySetting.NotifyType type, Boolean flag) {
user.getNotifySetting().updateNotifySetting(type, flag);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ public void updatePassword(Long userId, String oldPassword, String newPassword)
userProfileUpdateService.updatePassword(user, oldPassword, newPassword);
}

@Transactional
public void updateProfileImage(Long userId, UserProfileUpdateDto.ProfileImageReq request) {
User user = readUserOrThrow(userId);

userProfileUpdateService.updateProfileImage(user, request.profileImageUrl());
}

@Transactional
public UserProfileUpdateDto.NotifySettingUpdateReq activateNotification(Long userId, NotifySetting.NotifyType type) {
User user = readUserOrThrow(userId);
Expand All @@ -119,7 +126,8 @@ public UserProfileUpdateDto.NotifySettingUpdateReq deactivateNotification(Long u

@Transactional
public void deleteAccount(Long userId) {
if (!userService.isExistUser(userId)) throw new UserErrorException(UserErrorCode.NOT_FOUND);
if (!userService.isExistUser(userId))
throw new UserErrorException(UserErrorCode.NOT_FOUND);

// TODO: [2024-05-03] 하나라도 채팅방의 방장으로 참여하는 경우 삭제 불가능 처리

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import kr.co.pennyway.common.exception.StatusCode;
import kr.co.pennyway.domain.domains.user.exception.UserErrorCode;
import kr.co.pennyway.domain.domains.user.exception.UserErrorException;
import kr.co.pennyway.infra.common.exception.StorageErrorCode;
import kr.co.pennyway.infra.common.exception.StorageException;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
Expand Down Expand Up @@ -365,7 +367,10 @@ void updatePasswordValidationFail() throws Exception {
String newPasswordWithOnlySpecialCharacterAndWhiteSpace = "!@#$%^&*() ";
String newPasswordWithOnlySpecialCharacterAndEmoji = "!@#$%^&*()😊";
String newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace = "!@#$%^&*() 😊";
List<String> newPasswords = List.of(newPasswordWithBlank, newPasswordWithUnderLength, newPasswordWithOverLength, newPasswordWithOnlyAlphabet, newPasswordWithOnlyNumber, newPasswordWithOnlySpecialCharacter, newPasswordWithOnlyUpperCase, newPasswordWithOnlyLowerCase, newPasswordWithOnlyEmoji, newPasswordWithOnlyWhiteSpace, newPasswordWithOnlySpecialCharacterAndWhiteSpace, newPasswordWithOnlySpecialCharacterAndEmoji, newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace);
List<String> newPasswords = List.of(newPasswordWithBlank, newPasswordWithUnderLength, newPasswordWithOverLength, newPasswordWithOnlyAlphabet,
newPasswordWithOnlyNumber, newPasswordWithOnlySpecialCharacter, newPasswordWithOnlyUpperCase, newPasswordWithOnlyLowerCase,
newPasswordWithOnlyEmoji, newPasswordWithOnlyWhiteSpace, newPasswordWithOnlySpecialCharacterAndWhiteSpace,
newPasswordWithOnlySpecialCharacterAndEmoji, newPasswordWithOnlySpecialCharacterAndEmojiAndWhiteSpace);

String expectedErrorCode = String.valueOf(StatusCode.UNPROCESSABLE_CONTENT.getCode() * 10 + REQUIRED_PARAMETERS_MISSING_IN_REQUEST_BODY.getCode());

Expand Down Expand Up @@ -506,4 +511,48 @@ private ResultActions performDeleteAccountRequest() throws Exception {
.contentType("application/json"));
}
}

@Nested
@Order(7)
@DisplayName("[7] 사용자 프로필 이미지 등록 테스트")
class RegisterProfileImageTest {
@DisplayName("사용자 프로필 이미지 등록 요청 시, 존재하지 않는 이미지 경로인 경우 404 에러를 반환한다.")
@Test
@WithSecurityMockUser
void registerProfileImageNotFound() throws Exception {
// given
String profileImageUrl = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg";
willThrow(new StorageException(StorageErrorCode.NOT_FOUND)).given(userAccountUseCase)
.updateProfileImage(1L, new UserProfileUpdateDto.ProfileImageReq(profileImageUrl));

// when
ResultActions result = performRegisterProfileImageRequest(profileImageUrl);

// then
result.andExpect(status().isNotFound())
.andDo(print());
}

@DisplayName("사용자 프로필 이미지 정상 요청 시, 200 코드를 반환한다.")
@Test
@WithSecurityMockUser
void registerProfileImageSuccess() throws Exception {
// given
String profileImageUrl = "delete/profile/1/154aa3bd-da02-4311-a735-3bf7e4bb68d2_1717446100295.jpeg";

// when
ResultActions result = performRegisterProfileImageRequest(profileImageUrl);

// then
result.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("2000"))
.andDo(print());
}

private ResultActions performRegisterProfileImageRequest(String profileImageUrl) throws Exception {
return mockMvc.perform(put("/v2/users/me/profile-image")
.contentType("application/json")
.content(objectMapper.writeValueAsString(new UserProfileUpdateDto.ProfileImageReq(profileImageUrl))));
}
}
}
Loading

0 comments on commit 35710d0

Please sign in to comment.