diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java b/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java index 20fdfdc5..9f3c1bed 100644 --- a/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java +++ b/umbba-api/src/main/java/sopt/org/umbba/api/config/SecurityConfig.java @@ -25,7 +25,8 @@ public class SecurityConfig { // "/log-out", "/test", "/profile", "/health", "/actuator/health", "/alarm/qna", "/alarm/drink", - "/demo/**" + "/demo/**", + "/album/image" }; @Bean diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java new file mode 100644 index 00000000..d8eddaae --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/AlbumController.java @@ -0,0 +1,72 @@ +package sopt.org.umbba.api.controller.album; + +import static sopt.org.umbba.api.config.jwt.JwtProvider.*; +import static sopt.org.umbba.common.exception.SuccessType.*; +import static sopt.org.umbba.external.s3.S3BucketPrefix.*; + +import java.security.Principal; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.api.controller.album.dto.request.AlbumImgUrlRequestDto; +import sopt.org.umbba.api.controller.album.dto.request.CreateAlbumRequestDto; +import sopt.org.umbba.api.controller.album.dto.response.AlbumResponseDto; +import sopt.org.umbba.api.service.album.AlbumService; +import sopt.org.umbba.common.exception.dto.ApiResponse; +import sopt.org.umbba.external.s3.PreSignedUrlDto; +import sopt.org.umbba.external.s3.S3BucketPrefix; +import sopt.org.umbba.external.s3.S3Service; + +@RestController +@RequestMapping("/album") +@RequiredArgsConstructor +public class AlbumController { + + private final AlbumService albumService; + private final S3Service s3Service; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createAlbum(@Valid @RequestBody final CreateAlbumRequestDto request, final Principal principal, HttpServletResponse response) { + String imgUrl = s3Service.getS3ImgUrl(ALBUM_PREFIX.getValue(), request.getImgFileName()); + Long albumId = albumService.createAlbum(request, imgUrl, getUserFromPrincial(principal)); + response.setHeader("Location", "/album/" + albumId); + return ApiResponse.success(CREATE_ALBUM_SUCCESS); + } + + // PreSigned Url 이용 (클라이언트에서 해당 URL로 업로드) + @PatchMapping("/image") + @ResponseStatus(HttpStatus.OK) + public ApiResponse getImgPreSignedUrl(@RequestBody final AlbumImgUrlRequestDto request) { + return ApiResponse.success(GET_PRE_SIGNED_URL_SUCCESS, s3Service.getPreSignedUrl(S3BucketPrefix.of(request.getImgPrefix()))); + } + + @DeleteMapping("/{albumId}") + @ResponseStatus(HttpStatus.OK) + public ApiResponse deleteAlbum(@PathVariable final Long albumId, final Principal principal) { + String imgUrl = albumService.deleteAlbum(albumId, getUserFromPrincial(principal)); + s3Service.deleteS3Image(imgUrl); + return ApiResponse.success(DELETE_ALBUM_SUCCESS); + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse> getAlbumList(final Principal principal) { + return ApiResponse.success(GET_ALBUM_LIST_SUCCESS, albumService.getAlbumList(getUserFromPrincial(principal))); + } +} \ No newline at end of file diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java new file mode 100644 index 00000000..669e7126 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/AlbumImgUrlRequestDto.java @@ -0,0 +1,16 @@ +package sopt.org.umbba.api.controller.album.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class AlbumImgUrlRequestDto { + + private String imgPrefix; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java new file mode 100644 index 00000000..5b25abfb --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/request/CreateAlbumRequestDto.java @@ -0,0 +1,28 @@ +package sopt.org.umbba.api.controller.album.dto.request; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CreateAlbumRequestDto { + + @NotBlank(message = "제목은 필수 입력 값입니다.") + @Size(max = 15) + private String title; + + @NotBlank(message = "소개글은 필수 입력 값입니다.") + @Size(max = 32) + private String content; + + @NotBlank(message = "이미지 파일명은 필수 입력 값입니다.") + private String imgFileName; +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java new file mode 100644 index 00000000..96789566 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/controller/album/dto/response/AlbumResponseDto.java @@ -0,0 +1,30 @@ +package sopt.org.umbba.api.controller.album.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; +import sopt.org.umbba.domain.domain.album.Album; + +@Getter +@Builder +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class AlbumResponseDto { + + private Long albumId; + private String title; + private String content; + private String writer; + private String imgUrl; + + public static AlbumResponseDto of(Album album) { + return AlbumResponseDto.builder() + .albumId(album.getId()) + .title(album.getTitle()) + .content(album.getContent()) + .writer(album.getWriter()) + .imgUrl(album.getImgUrl()) + .build(); + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java b/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java new file mode 100644 index 00000000..d9a1a261 --- /dev/null +++ b/umbba-api/src/main/java/sopt/org/umbba/api/service/album/AlbumService.java @@ -0,0 +1,93 @@ +package sopt.org.umbba.api.service.album; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.api.controller.album.dto.request.CreateAlbumRequestDto; +import sopt.org.umbba.api.controller.album.dto.response.AlbumResponseDto; +import sopt.org.umbba.common.exception.ErrorType; +import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.album.Album; +import sopt.org.umbba.domain.domain.album.repository.AlbumRepository; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; +import sopt.org.umbba.domain.domain.user.repository.UserRepository; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AlbumService { + + private final AlbumRepository albumRepository; + private final UserRepository userRepository; + + @Transactional + public Long createAlbum(final CreateAlbumRequestDto request, final String imgUrl, final Long userId) { + + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + + Album album = Album.builder() + .title(request.getTitle()) + .content(request.getContent()) + .imgUrl(imgUrl) + .writer(user.getUsername()) + .parentchild(parentchild) + .build(); + albumRepository.save(album); + album.setParentchild(parentchild); + parentchild.addAlbum(album); + + return album.getId(); + } + + @Transactional + public String deleteAlbum(final Long albumId, final Long userId) { + + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + Album album = getAlbumById(albumId); + + album.deleteParentchild(); + parentchild.deleteAlbum(album); + albumRepository.delete(album); + + return album.getImgUrl(); + } + + public List getAlbumList(final Long userId) { + User user = getUserById(userId); + Parentchild parentchild = getParentchildByUser(user); + List albumList = albumRepository.findAllByParentchildOrderByCreatedAtDesc( + parentchild); + + return albumList.stream() + .map(AlbumResponseDto::of) + .collect(Collectors.toList()); + } + + private User getUserById(Long userId) { // TODO userId -> Parentchild 한번에 가져오기 + return userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorType.INVALID_USER) + ); + } + + private Album getAlbumById(Long albumId) { + return albumRepository.findById(albumId).orElseThrow( + () -> new CustomException(ErrorType.NOT_FOUND_ALBUM) + ); + } + + private Parentchild getParentchildByUser(User user) { + Parentchild parentchild = user.getParentChild(); + if (parentchild == null) { + throw new CustomException(ErrorType.USER_HAVE_NO_PARENTCHILD); + } + + return parentchild; + } +} diff --git a/umbba-api/src/main/java/sopt/org/umbba/api/service/example.txt b/umbba-api/src/main/java/sopt/org/umbba/api/service/example.txt deleted file mode 100644 index 945c9b46..00000000 --- a/umbba-api/src/main/java/sopt/org/umbba/api/service/example.txt +++ /dev/null @@ -1 +0,0 @@ -. \ No newline at end of file diff --git a/umbba-api/src/main/resources/application-dev1.yml b/umbba-api/src/main/resources/application-dev1.yml index 1627e262..7bb35838 100644 --- a/umbba-api/src/main/resources/application-dev1.yml +++ b/umbba-api/src/main/resources/application-dev1.yml @@ -34,6 +34,7 @@ cloud: static: ${CLOUD_REGION_DEV} s3: bucket: ${BUCKET_NAME_DEV} + bucketImg: ${IMG_BUCKET_DEV} stack: auto: false sqs: diff --git a/umbba-api/src/main/resources/application-dev2.yml b/umbba-api/src/main/resources/application-dev2.yml index e5b79300..69a59002 100644 --- a/umbba-api/src/main/resources/application-dev2.yml +++ b/umbba-api/src/main/resources/application-dev2.yml @@ -34,6 +34,7 @@ cloud: static: ${CLOUD_REGION_DEV} s3: bucket: ${BUCKET_NAME_DEV} + bucketImg: ${IMG_BUCKET_DEV} stack: auto: false sqs: diff --git a/umbba-api/src/main/resources/application-local.yml b/umbba-api/src/main/resources/application-local.yml index 1ea9fcea..ee4d0e6e 100644 --- a/umbba-api/src/main/resources/application-local.yml +++ b/umbba-api/src/main/resources/application-local.yml @@ -34,6 +34,7 @@ cloud: static: ${CLOUD_REGION_LOCAL} s3: bucket: ${BUCKET_NAME_LOCAL} + bucketImg: ${IMG_BUCKET_LOCAL} stack: auto: false sqs: diff --git a/umbba-api/src/main/resources/application-prod1.yml b/umbba-api/src/main/resources/application-prod1.yml index 38b2c3d7..e2a14e28 100644 --- a/umbba-api/src/main/resources/application-prod1.yml +++ b/umbba-api/src/main/resources/application-prod1.yml @@ -34,6 +34,7 @@ cloud: static: ${CLOUD_REGION_PROD} s3: bucket: ${BUCKET_NAME_PROD} + bucketImg: ${IMG_BUCKET_PROD} stack: auto: false sqs: diff --git a/umbba-api/src/main/resources/application-prod2.yml b/umbba-api/src/main/resources/application-prod2.yml index 5d924d7f..bff4413a 100644 --- a/umbba-api/src/main/resources/application-prod2.yml +++ b/umbba-api/src/main/resources/application-prod2.yml @@ -34,6 +34,7 @@ cloud: static: ${CLOUD_REGION_PROD} s3: bucket: ${BUCKET_NAME_PROD} + bucketImg: ${IMG_BUCKET_PROD} stack: auto: false sqs: diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java index 37bb4846..3a170993 100644 --- a/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/ErrorType.java @@ -34,6 +34,8 @@ public enum ErrorType { ALREADY_EXISTS_PARENT_CHILD_USER(HttpStatus.BAD_REQUEST, "이미 해당 유저의 부모자식 관계가 존재합니다."), ALREADY_QNA_LIST_FULL(HttpStatus.BAD_REQUEST, "이미 QNA 리스트가 가득 찼습니다"), + // Album + INVALID_BUCKET_PREFIX(HttpStatus.BAD_REQUEST, "유효하지 않은 S3 버킷 디렉토리명입니다."), /** * 401 UNAUTHORIZED @@ -61,6 +63,7 @@ public enum ErrorType { PARENTCHILD_HAVE_NO_QNALIST(HttpStatus.NOT_FOUND, "부모자식 관계가 가지고 있는 QnA 데이터가 없습니다."), PARENTCHILD_HAVE_NO_OPPONENT(HttpStatus.NOT_FOUND, "부모자식 관계에 1명만 참여하고 있습니다."), NOT_FOUND_SECTION(HttpStatus.NOT_FOUND, "해당 아이디와 일치하는 섹션이 없습니다."), + NOT_FOUND_ALBUM(HttpStatus.NOT_FOUND, "존재하지 않는 앨범입니다."), /** * About Apple (HttpStatus 고민) @@ -80,6 +83,9 @@ public enum ErrorType { DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 관련 에러가 발생했습니다."), FIREBASE_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파이어베이스 서버와의 연결에 실패했습니다."), FAIL_TO_SEND_PUSH_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "푸시 알림 메세지 전송에 실패했습니다."), + FAIL_TO_GET_IMAGE_PRE_SIGNED_URL(HttpStatus.INTERNAL_SERVER_ERROR, "PreSigned Url을 가져오는 데 실패했습니다."), + FAIL_TO_DELETE_IMAGE(HttpStatus.INTERNAL_SERVER_ERROR, "S3 버킷에서 이미지를 삭제하는 데 실패했습니다."), + S3_BUCKET_GET_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 버킷에서 이미지를 불러오는 데 실패했습니다."), // ETC INDEX_OUT_OF_BOUNDS(HttpStatus.INTERNAL_SERVER_ERROR, "인덱스 범위를 초과했습니다."), diff --git a/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java index 14760146..25226397 100644 --- a/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java +++ b/umbba-common/src/main/java/sopt/org/umbba/common/exception/SuccessType.java @@ -29,6 +29,10 @@ public enum SuccessType { TEST_SUCCESS(HttpStatus.OK, "데모데이 테스트용 API 호출에 성공했습니다."), RESTART_QNA_SUCCESS(HttpStatus.OK, "7일 이후 문답이 정상적으로 시작되었습니다."), GET_USER_FIRST_ENTRY_SUCCESS(HttpStatus.OK, "유저의 첫 진입여부 조회에 성공했습니다."), + GET_PRE_SIGNED_URL_SUCCESS(HttpStatus.OK, "PreSigned Url 조회에 성공했습니다."), + IMAGE_S3_DELETE_SUCCESS(HttpStatus.OK, "S3 버킷에서 이미지를 삭제하는 데 성공했습니다."), + DELETE_ALBUM_SUCCESS(HttpStatus.OK, "앨범의 기록 삭제에 성공했습니다."), + GET_ALBUM_LIST_SUCCESS(HttpStatus.OK, "앨범의 기록 목록 조회에 성공했습니다."), /** @@ -37,6 +41,7 @@ public enum SuccessType { CREATE_PARENT_CHILD_SUCCESS(HttpStatus.CREATED, "온보딩 정보를 입력받아 부모자식 관계를 생성하는 데 성공했습니다."), MATCH_PARENT_CHILD_SUCCESS(HttpStatus.CREATED, "부모자식 관계 매칭에 성공했습니다."), ANSWER_TODAY_QUESTION_SUCCESS(HttpStatus.CREATED, "오늘의 일일문답에 답변을 완료하였습니다."), + CREATE_ALBUM_SUCCESS(HttpStatus.CREATED, "앨범의 기록 등록에 성공했습니다."), ; diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java new file mode 100644 index 00000000..342cccb3 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/Album.java @@ -0,0 +1,66 @@ +package sopt.org.umbba.domain.domain.album; + +import javax.persistence.Column; +import javax.persistence.ConstraintMode; +import javax.persistence.Entity; +import javax.persistence.ForeignKey; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; +import sopt.org.umbba.domain.domain.user.User; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Album extends AuditingTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "album_id") + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @Column(columnDefinition = "TEXT") + private String imgUrl; + + @Column(nullable = false) + private String writer; + + @ManyToOne + @JoinColumn(name = "parentchild_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private Parentchild parentchild; + + @Builder + private Album(String title, String content, String imgUrl, String writer, Parentchild parentchild) { + this.title = title; + this.content = content; + this.imgUrl = imgUrl; + this.writer = writer; + this.parentchild = parentchild; + } + + public void setParentchild(Parentchild parentchild) { + this.parentchild = parentchild; + + if (!parentchild.getAlbumList().contains(this)) { + parentchild.getAlbumList().add(this); + } + } + + public void deleteParentchild() { + this.parentchild = null; + } +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java new file mode 100644 index 00000000..ec073f38 --- /dev/null +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/album/repository/AlbumRepository.java @@ -0,0 +1,14 @@ +package sopt.org.umbba.domain.domain.album.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import sopt.org.umbba.domain.domain.album.Album; +import sopt.org.umbba.domain.domain.parentchild.Parentchild; + +public interface AlbumRepository extends JpaRepository { + + List findAllByParentchildOrderByCreatedAtDesc(Parentchild parentchild); + +} diff --git a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java index 31a19aa2..7a88e0fd 100644 --- a/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java +++ b/umbba-domain/src/main/java/sopt/org/umbba/domain/domain/parentchild/Parentchild.java @@ -6,6 +6,7 @@ import org.hibernate.annotations.Where; import sopt.org.umbba.common.exception.ErrorType; import sopt.org.umbba.common.exception.model.CustomException; +import sopt.org.umbba.domain.domain.album.Album; import sopt.org.umbba.domain.domain.common.AuditingTimeEntity; import sopt.org.umbba.domain.domain.qna.OnboardingAnswer; import sopt.org.umbba.domain.domain.qna.QnA; @@ -34,6 +35,9 @@ public class Parentchild extends AuditingTimeEntity { @JoinColumn(name = "parentchild_id") private List qnaList; + @OneToMany(mappedBy = "parentchild", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + private final List albumList = new ArrayList<>(); + @Column(nullable = false) private int count; @@ -100,4 +104,17 @@ public void addQna(QnA qnA) { qnaList.add(qnA); } + public void addAlbum(Album album) { + this.albumList.add(album); + if (album.getParentchild() != this) { + album.setParentchild(this); + } + } + + public void deleteAlbum(Album album) { + if (this.albumList.contains(album)) { + this.albumList.remove(album); + } + } + } diff --git a/umbba-external/build.gradle b/umbba-external/build.gradle index 1bc7fa04..07adf909 100644 --- a/umbba-external/build.gradle +++ b/umbba-external/build.gradle @@ -10,6 +10,10 @@ dependencies { // for JsonIgnore implementation group: "io.jsonwebtoken", name: "jjwt-jackson", version: "0.11.2" + // AWS sdk + implementation("software.amazon.awssdk:bom:2.21.0") + implementation("software.amazon.awssdk:s3:2.21.0") + // spring webflux // implementation "org.springframework.boot:spring-boot-starter-webflux" } \ No newline at end of file diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java new file mode 100644 index 00000000..0f5554fe --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/PreSignedUrlDto.java @@ -0,0 +1,23 @@ +package sopt.org.umbba.external.s3; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class PreSignedUrlDto { + + private String fileName; + private String url; + + public static PreSignedUrlDto of(String fileName, String url) { + return PreSignedUrlDto.builder() + .fileName(fileName) + .url(url) + .build(); + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java new file mode 100644 index 00000000..5c1b3176 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3BucketPrefix.java @@ -0,0 +1,25 @@ +package sopt.org.umbba.external.s3; + +import static sopt.org.umbba.common.exception.ErrorType.*; + +import java.util.Arrays; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import sopt.org.umbba.common.exception.model.CustomException; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum S3BucketPrefix { + ALBUM_PREFIX("album/"); + + private final String value; + + public static S3BucketPrefix of(String value) { + return Arrays.stream(S3BucketPrefix.values()) + .filter(prefix -> value.equals(prefix.value)) + .findFirst() + .orElseThrow(() -> new CustomException(INVALID_BUCKET_PREFIX)); + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java new file mode 100644 index 00000000..6a21a903 --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/S3Service.java @@ -0,0 +1,115 @@ +package sopt.org.umbba.external.s3; + +import static sopt.org.umbba.common.exception.ErrorType.*; + +import java.net.URL; +import java.time.Duration; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetUrlRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import sopt.org.umbba.common.exception.model.CustomException; + +@Slf4j +@Component +public class S3Service { + + private static final Long PRE_SIGNED_URL_EXPIRE_MINUTE = 1L; // 만료시간 1분 + private static final String IMAGE_EXTENSION = ".jpg"; + private static final String AWS_DOMAIN = "amazonaws.com/"; + + private final String bucketName; + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + + public S3Service(@Value("${cloud.aws.s3.bucketImg}") final String bucketName, final S3Client s3Client, final S3Presigner s3Presigner) { + this.bucketName = bucketName; + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; + } + + // 이미지 저장을 위한 PreSigned Url 발급 + public PreSignedUrlDto getPreSignedUrl(final S3BucketPrefix prefix) { + final String fileName = generateImageFileName(); // UUID 문자열 + final String key = prefix.getValue() + fileName; + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(key).build(); + + PutObjectPresignRequest preSignedUrlRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(PRE_SIGNED_URL_EXPIRE_MINUTE)) + .putObjectRequest(request).build(); + + String url = s3Presigner.presignPutObject(preSignedUrlRequest).url().toString(); + return PreSignedUrlDto.of(fileName, url); + } catch (RuntimeException e) { + throw new CustomException(FAIL_TO_GET_IMAGE_PRE_SIGNED_URL); + } + } + + private String generateImageFileName() { + return UUID.randomUUID() + IMAGE_EXTENSION; + } + + // S3 버킷으로부터 이미지 삭제 + public void deleteS3Image(String url) { + String key = getKeyByUrl(url); + try { + s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> + builder.bucket(bucketName) + .key(key).build()); + } catch (RuntimeException e) { + throw new CustomException(FAIL_TO_DELETE_IMAGE); + } + } + + // 파일명으로부터 S3 Bucket URL 조회 + public String getS3ImgUrl(String prefix, String fileName) { + + String imageKey = prefix + fileName; + + try { + GetUrlRequest request = GetUrlRequest.builder() + .bucket(bucketName) + .key(imageKey) + .build(); + + URL imageUrl = s3Client.utilities().getUrl(request); + + String urlWithKey = "https://" + bucketName + ".s3.ap-northeast-2.amazonaws.com/" + imageKey; + if (urlWithKey.equals(imageUrl.toString())) { + log.info("S3에 저장된 이미지 Url: {}", imageUrl); + return imageUrl.toString(); + } + throw new CustomException(S3_BUCKET_GET_IMAGE_ERROR); + } catch (S3Exception e) { + throw new CustomException(S3_BUCKET_GET_IMAGE_ERROR); + } + } + + private String getKeyByUrl(String imgUrl) { + + int index = imgUrl.indexOf(AWS_DOMAIN); + String imageKey = ""; + if (index != -1) { + imageKey = imgUrl.substring(index + AWS_DOMAIN.length()); + log.info("imageKey substring으로 가져옴: {}", imageKey); + } else { + log.error("imageKey substring으로 가져오기 실패"); + } + + return imageKey; + } +} diff --git a/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java b/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java new file mode 100644 index 00000000..3131612c --- /dev/null +++ b/umbba-external/src/main/java/sopt/org/umbba/external/s3/config/S3Config.java @@ -0,0 +1,57 @@ +package sopt.org.umbba.external.s3.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; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + private static String AWS_ACCESS_KEY_ID = "aws.accessKeyId"; + private static String AWS_SECRET_ACCESS_KEY = "aws.secretAccessKey"; + + private final String accessKey; + private final String secretKey; + private final String regionString; + + public S3Config(@Value("${cloud.aws.credentials.accessKey}") final String accessKey, + @Value("${cloud.aws.credentials.secretKey}") final String secretKey, + @Value("${cloud.aws.region.static}") 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(); + } + + @Bean + public S3Presigner getS3PreSigner() { + return S3Presigner.builder() + .region(getRegion()) + .credentialsProvider(systemPropertyCredentialsProvider()) + .build(); + } +} \ No newline at end of file