From 83089d52e91ab9ce115a272275d2ee61c806d72c Mon Sep 17 00:00:00 2001 From: chaewonni <113420297+chaewonni@users.noreply.github.com> Date: Tue, 9 Jul 2024 21:55:39 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EA=B4=80=EB=A0=A8=20API=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(S3=20Multipart=20=EA=B4=80=EB=A0=A8)=20(#?= =?UTF-8?q?26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [chore] #23 add required dependency in build.gradle * [feat] #23 s3 related setting * [feat] #23 create exception and add exception in globalExceptionHandler * [feat] #23 create user logic * [chore] #23 delete unnecessary file (UserInfoService) --- build.gradle | 4 + .../server/advice/GlobalExceptionHandler.java | 18 ++++ .../kkumulkkum/server/config/AwsConfig.java | 47 +++++++++++ .../server/controller/UserController.java | 51 ++++++++++++ .../kkumulkkum/server/domain/UserInfo.java | 12 +++ .../dto/user/request/ImageUpdateDto.java | 10 +++ .../dto/user/request/NameUpdateDto.java | 10 +++ .../server/dto/user/response/UserDto.java | 23 +++++ .../server/dto/user/response/UserNameDto.java | 11 +++ .../server/exception/AwsException.java | 11 +++ .../server/exception/code/AwsErrorCode.java | 21 +++++ .../exception/code/BusinessErrorCode.java | 1 + .../kkumulkkum/server/external/S3Service.java | 83 +++++++++++++++++++ .../server/repository/UserInfoRepository.java | 4 + .../server/service/user/UserService.java | 76 +++++++++++++++++ .../service/userInfo/UserInfoRetriever.java | 16 ++++ .../service/userInfo/UserInfoService.java | 7 -- 17 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/kkumulkkum/server/config/AwsConfig.java create mode 100644 src/main/java/org/kkumulkkum/server/controller/UserController.java create mode 100644 src/main/java/org/kkumulkkum/server/dto/user/request/ImageUpdateDto.java create mode 100644 src/main/java/org/kkumulkkum/server/dto/user/request/NameUpdateDto.java create mode 100644 src/main/java/org/kkumulkkum/server/dto/user/response/UserDto.java create mode 100644 src/main/java/org/kkumulkkum/server/dto/user/response/UserNameDto.java create mode 100644 src/main/java/org/kkumulkkum/server/exception/AwsException.java create mode 100644 src/main/java/org/kkumulkkum/server/exception/code/AwsErrorCode.java create mode 100644 src/main/java/org/kkumulkkum/server/external/S3Service.java delete mode 100644 src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoService.java diff --git a/build.gradle b/build.gradle index 947e707..b6dfc98 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,10 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // AWS S3 + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") } tasks.named('test') { diff --git a/src/main/java/org/kkumulkkum/server/advice/GlobalExceptionHandler.java b/src/main/java/org/kkumulkkum/server/advice/GlobalExceptionHandler.java index b9ce99c..811755f 100644 --- a/src/main/java/org/kkumulkkum/server/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/kkumulkkum/server/advice/GlobalExceptionHandler.java @@ -2,9 +2,11 @@ import lombok.extern.slf4j.Slf4j; import org.kkumulkkum.server.exception.AuthException; +import org.kkumulkkum.server.exception.AwsException; import org.kkumulkkum.server.exception.BusinessException; import org.kkumulkkum.server.exception.MeetingException; import org.kkumulkkum.server.exception.code.AuthErrorCode; +import org.kkumulkkum.server.exception.code.AwsErrorCode; import org.kkumulkkum.server.exception.code.BusinessErrorCode; import org.kkumulkkum.server.exception.code.MeetingErrorCode; import org.springframework.http.ResponseEntity; @@ -12,6 +14,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.NoHandlerFoundException; @Slf4j @@ -34,6 +37,14 @@ public ResponseEntity handleAuthException(AuthException e) { .body(e.getErrorCode()); } + @ExceptionHandler(value = {AwsException.class}) + public ResponseEntity handleAwsException(AwsException e) { + log.error("GlobalExceptionHandler catch AwsException : {}", e.getErrorCode().getMessage()); + return ResponseEntity + .status(e.getErrorCode().getHttpStatus()) + .body(e.getErrorCode()); + } + // 도메인 관련된 에러가 아닐 경우 @ExceptionHandler(value = {BusinessException.class}) public ResponseEntity handleBusinessException(BusinessException e) { @@ -60,6 +71,13 @@ public ResponseEntity handleException(MethodArgumentNotValidE .body(BusinessErrorCode.INVALID_ARGUMENTS); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxSizeException(MaxUploadSizeExceededException e) { + return ResponseEntity + .status(BusinessErrorCode.PAYLOAD_TOO_LARGE.getHttpStatus()) + .body(BusinessErrorCode.PAYLOAD_TOO_LARGE); + } + // 기본 예외 @ExceptionHandler(value = {Exception.class}) public ResponseEntity handleException(Exception e) { diff --git a/src/main/java/org/kkumulkkum/server/config/AwsConfig.java b/src/main/java/org/kkumulkkum/server/config/AwsConfig.java new file mode 100644 index 0000000..49817dd --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/config/AwsConfig.java @@ -0,0 +1,47 @@ +package org.kkumulkkum.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.SystemPropertyCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class AwsConfig { + + private static final String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static final String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public AwsConfig(@Value("${aws-property.access-key}") final String accessKey, + @Value("${aws-property.secret-key}") final String secretKey, + @Value("${aws-property.aws-region}") final String regionString) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.regionString = regionString; + } + + @Bean + public SystemPropertyCredentialsProvider systemPropertyCredentialsProvider() { + System.setProperty(AWS_ACCESS_KEY_ID, accessKey); + System.setProperty(AWS_SECRET_ACCESS_KEY, secretKey); + return SystemPropertyCredentialsProvider.create(); + } + + @Bean + public Region getRegion() { + return Region.of(regionString); + } + + @Bean + public S3Client getS3Client() { + return S3Client.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } +} diff --git a/src/main/java/org/kkumulkkum/server/controller/UserController.java b/src/main/java/org/kkumulkkum/server/controller/UserController.java new file mode 100644 index 0000000..4576eea --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/controller/UserController.java @@ -0,0 +1,51 @@ +package org.kkumulkkum.server.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.kkumulkkum.server.annotation.UserId; +import org.kkumulkkum.server.dto.user.request.ImageUpdateDto; +import org.kkumulkkum.server.dto.user.response.UserDto; +import org.kkumulkkum.server.dto.user.response.UserNameDto; +import org.kkumulkkum.server.service.user.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class UserController { + + private final UserService userService; + + @PatchMapping("/users/me/image") + public ResponseEntity updateImage( + @UserId final Long userId, + @Valid @ModelAttribute final ImageUpdateDto imageUpdateDto + ) { + userService.updateImage(userId, imageUpdateDto); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/users/me/image") + public ResponseEntity deleteImage( + @UserId final Long userId + ) { + userService.deleteImage(userId); + return ResponseEntity.ok().build(); + } + + @PatchMapping("/users/me/name") + public ResponseEntity updateName( + @UserId final Long userId, + @Valid @RequestBody final UserNameDto userNameDto + ) { + return ResponseEntity.ok().body(userService.updateName(userId, userNameDto)); + } + + @GetMapping("/users/me") + public ResponseEntity getUserInfo( + @UserId final Long userId + ) { + return ResponseEntity.ok().body(userService.getUserInfo(userId)); + } +} diff --git a/src/main/java/org/kkumulkkum/server/domain/UserInfo.java b/src/main/java/org/kkumulkkum/server/domain/UserInfo.java index ce9eb2a..da25c5c 100644 --- a/src/main/java/org/kkumulkkum/server/domain/UserInfo.java +++ b/src/main/java/org/kkumulkkum/server/domain/UserInfo.java @@ -47,4 +47,16 @@ public UserInfo(String name, this.user = user; this.tardySum = 0L; } + + public void updateImage(String imageUrl) { + this.profileImg = imageUrl; + } + + public void deleteImage() { + this.profileImg = null; + } + + public void updateName(String name) { + this.name = name; + } } diff --git a/src/main/java/org/kkumulkkum/server/dto/user/request/ImageUpdateDto.java b/src/main/java/org/kkumulkkum/server/dto/user/request/ImageUpdateDto.java new file mode 100644 index 0000000..84c5e7f --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/dto/user/request/ImageUpdateDto.java @@ -0,0 +1,10 @@ +package org.kkumulkkum.server.dto.user.request; + +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record ImageUpdateDto( + @NotNull + MultipartFile image +) { +} diff --git a/src/main/java/org/kkumulkkum/server/dto/user/request/NameUpdateDto.java b/src/main/java/org/kkumulkkum/server/dto/user/request/NameUpdateDto.java new file mode 100644 index 0000000..445caf2 --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/dto/user/request/NameUpdateDto.java @@ -0,0 +1,10 @@ +package org.kkumulkkum.server.dto.user.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NameUpdateDto( + @NotBlank @Size(max = 5) + String name +) { +} diff --git a/src/main/java/org/kkumulkkum/server/dto/user/response/UserDto.java b/src/main/java/org/kkumulkkum/server/dto/user/response/UserDto.java new file mode 100644 index 0000000..a109d13 --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/dto/user/response/UserDto.java @@ -0,0 +1,23 @@ +package org.kkumulkkum.server.dto.user.response; + +import org.kkumulkkum.server.domain.UserInfo; + +public record UserDto( + String name, + int level, + int promiseCount, + int tardyCount, + Long tardySum, + String profileImg +) { + public static UserDto from(UserInfo userInfo) { + return new UserDto( + userInfo.getName(), + userInfo.getLevel(), + userInfo.getPromiseCount(), + userInfo.getTardyCount(), + userInfo.getTardySum(), + userInfo.getProfileImg() + ); + } +} diff --git a/src/main/java/org/kkumulkkum/server/dto/user/response/UserNameDto.java b/src/main/java/org/kkumulkkum/server/dto/user/response/UserNameDto.java new file mode 100644 index 0000000..44e683b --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/dto/user/response/UserNameDto.java @@ -0,0 +1,11 @@ +package org.kkumulkkum.server.dto.user.response; + +import org.kkumulkkum.server.domain.UserInfo; + +public record UserNameDto( + String name +) { + public static UserNameDto from(UserInfo userInfo) { + return new UserNameDto(userInfo.getName()); + } +} diff --git a/src/main/java/org/kkumulkkum/server/exception/AwsException.java b/src/main/java/org/kkumulkkum/server/exception/AwsException.java new file mode 100644 index 0000000..1db508e --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/exception/AwsException.java @@ -0,0 +1,11 @@ +package org.kkumulkkum.server.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.kkumulkkum.server.exception.code.AwsErrorCode; + +@Getter +@RequiredArgsConstructor +public class AwsException extends RuntimeException { + private final AwsErrorCode errorCode; +} diff --git a/src/main/java/org/kkumulkkum/server/exception/code/AwsErrorCode.java b/src/main/java/org/kkumulkkum/server/exception/code/AwsErrorCode.java new file mode 100644 index 0000000..c1f2d07 --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/exception/code/AwsErrorCode.java @@ -0,0 +1,21 @@ +package org.kkumulkkum.server.exception.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AwsErrorCode implements DefaultErrorCode { + // 400 Bad Request + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, 40080, "이미지 확장자는 jpg, png, webp만 가능합니다."), + IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST,40081, "이미지 사이즈는 5MB를 넘을 수 없습니다."), + + // 404 Not Found + NOT_FOUND_IMAGE(HttpStatus.NOT_FOUND, 40480, "삭제할 이미지를 찾을 수 없습니다."), + ; + + private HttpStatus httpStatus; + private int code; + private String message; +} diff --git a/src/main/java/org/kkumulkkum/server/exception/code/BusinessErrorCode.java b/src/main/java/org/kkumulkkum/server/exception/code/BusinessErrorCode.java index c2375a8..8de0a4e 100644 --- a/src/main/java/org/kkumulkkum/server/exception/code/BusinessErrorCode.java +++ b/src/main/java/org/kkumulkkum/server/exception/code/BusinessErrorCode.java @@ -10,6 +10,7 @@ public enum BusinessErrorCode implements DefaultErrorCode { // 400 Bad Request BAD_REQUEST(HttpStatus.BAD_REQUEST,40000, "잘못된 요청입니다."), INVALID_ARGUMENTS(HttpStatus.BAD_REQUEST, 40001, "인자의 형식이 올바르지 않습니다."), + PAYLOAD_TOO_LARGE(HttpStatus.BAD_REQUEST,40002,"최대 업로드 크기를 초과했습니다."), // 404 Not Found NOT_FOUND(HttpStatus.NOT_FOUND,40400, "요청한 정보를 찾을 수 없습니다."), NOT_FOUND_END_POINT(HttpStatus.NOT_FOUND,40401, "요청한 엔드포인트를 찾을 수 없습니다."), diff --git a/src/main/java/org/kkumulkkum/server/external/S3Service.java b/src/main/java/org/kkumulkkum/server/external/S3Service.java new file mode 100644 index 0000000..2eff757 --- /dev/null +++ b/src/main/java/org/kkumulkkum/server/external/S3Service.java @@ -0,0 +1,83 @@ +package org.kkumulkkum.server.external; + +import org.kkumulkkum.server.config.AwsConfig; +import org.kkumulkkum.server.exception.AwsException; +import org.kkumulkkum.server.exception.code.AwsErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +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.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +public class S3Service { + + private final String bucketName; + private final AwsConfig awsConfig; + private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp"); + + public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) { + this.bucketName = bucketName; + this.awsConfig = awsConfig; + } + + public String uploadImage(String directoryPath, MultipartFile image) throws IOException { + final String extension = getFileExtension(image.getOriginalFilename()); + final String key = directoryPath + generateImageFileName(extension); + final S3Client s3Client = awsConfig.getS3Client(); + + validateExtension(image); + validateFileSize(image); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(image.getContentType()) + .contentDisposition("inline") + .build(); + + RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); + s3Client.putObject(request, requestBody); + return key; + } + + public void deleteImage(String key) throws IOException { + final S3Client s3Client = awsConfig.getS3Client(); + + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key) + .build() + ); + } + + private String getFileExtension(String fileName) { + return fileName.substring(fileName.lastIndexOf(".") + 1); + } + + private String generateImageFileName(String extension) { + return UUID.randomUUID() + "." + extension; + } + + private void validateExtension(MultipartFile image) { + String contentType = image.getContentType(); + if (!IMAGE_EXTENSIONS.contains(contentType)) { + throw new AwsException(AwsErrorCode.INVALID_IMAGE_EXTENSION); + } + } + + private static final Long MAX_FILE_SIZE = 5 * 1024 * 1024L; + + private void validateFileSize(MultipartFile image) { + if (image.getSize() > MAX_FILE_SIZE) { + throw new AwsException(AwsErrorCode.IMAGE_SIZE_EXCEEDED); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/kkumulkkum/server/repository/UserInfoRepository.java b/src/main/java/org/kkumulkkum/server/repository/UserInfoRepository.java index d3442dc..19d308d 100644 --- a/src/main/java/org/kkumulkkum/server/repository/UserInfoRepository.java +++ b/src/main/java/org/kkumulkkum/server/repository/UserInfoRepository.java @@ -3,5 +3,9 @@ import org.kkumulkkum.server.domain.UserInfo; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface UserInfoRepository extends JpaRepository { + + Optional findByUserId(Long id); } diff --git a/src/main/java/org/kkumulkkum/server/service/user/UserService.java b/src/main/java/org/kkumulkkum/server/service/user/UserService.java index cd60fe1..f04093c 100644 --- a/src/main/java/org/kkumulkkum/server/service/user/UserService.java +++ b/src/main/java/org/kkumulkkum/server/service/user/UserService.java @@ -1,7 +1,83 @@ package org.kkumulkkum.server.service.user; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kkumulkkum.server.domain.UserInfo; +import org.kkumulkkum.server.dto.user.request.ImageUpdateDto; +import org.kkumulkkum.server.dto.user.response.UserDto; +import org.kkumulkkum.server.dto.user.response.UserNameDto; +import org.kkumulkkum.server.exception.AwsException; +import org.kkumulkkum.server.exception.code.AwsErrorCode; +import org.kkumulkkum.server.external.S3Service; +import org.kkumulkkum.server.service.userInfo.UserInfoRetriever; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; + +@Slf4j @Service +@RequiredArgsConstructor public class UserService { + + private final UserInfoRetriever userInfoRetriever; + private final S3Service s3Service; + static final String PROFILE_S3_UPLOAD_FOLDER = "profile/"; + + @Transactional + public void updateImage( + final Long userId, final ImageUpdateDto imageUpdateDto + ) { + UserInfo userInfo = userInfoRetriever.findByUserId(userId); + + if (userInfo.getProfileImg() != null) { + try { + s3Service.deleteImage(userInfo.getProfileImg()); + } catch (AwsException e) { + throw new AwsException(e.getErrorCode()); + } catch (RuntimeException | IOException e) { + throw new RuntimeException(e.getMessage()); + } + } + + try { + userInfo.updateImage(s3Service.uploadImage(PROFILE_S3_UPLOAD_FOLDER, imageUpdateDto.image())); + } catch (AwsException e) { + throw new AwsException(e.getErrorCode()); + } catch (RuntimeException | IOException e) { + throw new RuntimeException(e.getMessage()); + } + } + + @Transactional + public void deleteImage(final Long userId) { + UserInfo userInfo = userInfoRetriever.findByUserId(userId); + + if (userInfo.getProfileImg() == null) { + throw new AwsException(AwsErrorCode.NOT_FOUND_IMAGE); + } + + try { + s3Service.deleteImage(userInfo.getProfileImg()); + } catch (AwsException e) { + throw new AwsException(e.getErrorCode()); + } catch (RuntimeException | IOException e) { + throw new RuntimeException(e.getMessage()); + } + userInfo.deleteImage(); + } + + @Transactional + public UserNameDto updateName(final Long userId, final UserNameDto userNameDto) { + UserInfo userInfo = userInfoRetriever.findByUserId(userId); + userInfo.updateName(userNameDto.name()); + + return UserNameDto.from(userInfo); + } + + @Transactional(readOnly = true) + public UserDto getUserInfo(final Long userId) { + UserInfo userInfo = userInfoRetriever.findByUserId(userId); + return UserDto.from(userInfo); + } } diff --git a/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoRetriever.java b/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoRetriever.java index c7c0eb7..4088242 100644 --- a/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoRetriever.java +++ b/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoRetriever.java @@ -1,4 +1,20 @@ package org.kkumulkkum.server.service.userInfo; +import lombok.RequiredArgsConstructor; +import org.kkumulkkum.server.domain.UserInfo; +import org.kkumulkkum.server.exception.UserException; +import org.kkumulkkum.server.exception.code.UserErrorCode; +import org.kkumulkkum.server.repository.UserInfoRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor public class UserInfoRetriever { + + private final UserInfoRepository userInfoRepository; + + public UserInfo findByUserId(final Long id) { + return userInfoRepository.findByUserId(id) + .orElseThrow(() -> new UserException(UserErrorCode.NOT_FOUND_USER)); + } } diff --git a/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoService.java b/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoService.java deleted file mode 100644 index 32a27ec..0000000 --- a/src/main/java/org/kkumulkkum/server/service/userInfo/UserInfoService.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.kkumulkkum.server.service.userInfo; - -import org.springframework.stereotype.Service; - -@Service -public class UserInfoService { -}