diff --git a/src/main/java/org/atdev/artrip/domain/exhibit/service/ExhibitService.java b/src/main/java/org/atdev/artrip/domain/exhibit/service/ExhibitService.java index 98496d4..5e03218 100644 --- a/src/main/java/org/atdev/artrip/domain/exhibit/service/ExhibitService.java +++ b/src/main/java/org/atdev/artrip/domain/exhibit/service/ExhibitService.java @@ -8,6 +8,8 @@ import org.atdev.artrip.domain.home.converter.HomeConverter; import org.atdev.artrip.global.apipayload.code.status.ExhibitError; import org.atdev.artrip.global.apipayload.exception.GeneralException; +import org.atdev.artrip.global.s3.service.S3Service; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.springframework.stereotype.Service; @Service @@ -16,20 +18,22 @@ public class ExhibitService { private final ExhibitRepository exhibitRepository; private final HomeConverter homeConverter; + private final S3Service s3Service; private final FavoriteExhibitRepository favoriteExhibitRepository; - - public ExhibitDetailResponse getExhibitDetail(Long exhibitId, Long userId) { + public ExhibitDetailResponse getExhibitDetail(Long exhibitId, Long userId, ImageResizeRequest resize) { Exhibit exhibit = exhibitRepository.findById(exhibitId) .orElseThrow(() -> new GeneralException(ExhibitError._EXHIBIT_NOT_FOUND)); + String resizedPosterUrl = s3Service.buildResizeUrl(exhibit.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()); + boolean isFavorite = false; if (userId != null ) { isFavorite = favoriteExhibitRepository.existsActive(userId, exhibitId); } - return homeConverter.toHomeExhibitResponse(exhibit, isFavorite); + return homeConverter.toHomeExhibitResponse(exhibit, isFavorite, resizedPosterUrl); } diff --git a/src/main/java/org/atdev/artrip/domain/exhibit/web/controller/ExhibitController.java b/src/main/java/org/atdev/artrip/domain/exhibit/web/controller/ExhibitController.java index 94138f7..166f0d7 100644 --- a/src/main/java/org/atdev/artrip/domain/exhibit/web/controller/ExhibitController.java +++ b/src/main/java/org/atdev/artrip/domain/exhibit/web/controller/ExhibitController.java @@ -11,7 +11,9 @@ import org.atdev.artrip.global.apipayload.CommonResponse; import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.HomeError; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.atdev.artrip.global.swagger.ApiErrorResponses; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -52,10 +54,12 @@ public ResponseEntity>> getGenres(){ @GetMapping("/{id}") public ResponseEntity> getExhibit( @PathVariable Long id, - @AuthenticationPrincipal UserDetails userDetails) { - + @AuthenticationPrincipal UserDetails userDetails, + @ParameterObject ImageResizeRequest resize + ){ + Long userId = getUserId(userDetails); - ExhibitDetailResponse exhibit= exhibitService.getExhibitDetail(id, userId); + ExhibitDetailResponse exhibit= exhibitService.getExhibitDetail(id, userId, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibit)); } diff --git a/src/main/java/org/atdev/artrip/domain/home/converter/HomeConverter.java b/src/main/java/org/atdev/artrip/domain/home/converter/HomeConverter.java index 30f8e4c..416f198 100644 --- a/src/main/java/org/atdev/artrip/domain/home/converter/HomeConverter.java +++ b/src/main/java/org/atdev/artrip/domain/home/converter/HomeConverter.java @@ -52,7 +52,7 @@ public HomeListResponse toHomeExhibitListResponse(Exhibit exhibit, boolean isFav .build(); } - public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit, boolean isFavorite) { + public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit, boolean isFavorite, String resizePosterUrl) { var hall = exhibit.getExhibitHall(); String period = exhibit.getStartDate().format(formatter) + " - " + exhibit.getEndDate().format(formatter); @@ -64,7 +64,7 @@ public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit, boolean isFa .exhibitId(exhibit.getExhibitId()) .title(exhibit.getTitle()) .description(exhibit.getDescription()) - .posterUrl(exhibit.getPosterUrl()) + .posterUrl(resizePosterUrl) .ticketUrl(exhibit.getTicketUrl()) .status(exhibit.getStatus()) .exhibitPeriod(period) diff --git a/src/main/java/org/atdev/artrip/domain/home/service/HomeService.java b/src/main/java/org/atdev/artrip/domain/home/service/HomeService.java index b6b671f..763ae18 100644 --- a/src/main/java/org/atdev/artrip/domain/home/service/HomeService.java +++ b/src/main/java/org/atdev/artrip/domain/home/service/HomeService.java @@ -1,6 +1,7 @@ package org.atdev.artrip.domain.home.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.atdev.artrip.domain.auth.repository.UserRepository; import org.atdev.artrip.domain.exhibit.data.Exhibit; import org.atdev.artrip.domain.exhibit.web.dto.request.ExhibitFilterRequest; @@ -19,6 +20,8 @@ import org.atdev.artrip.global.apipayload.code.status.UserError; import org.atdev.artrip.global.apipayload.exception.GeneralException; +import org.atdev.artrip.global.s3.service.S3Service; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -29,6 +32,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class HomeService { private final ExhibitRepository exhibitRepository; @@ -36,6 +40,7 @@ public class HomeService { private final ExhibitHallRepository exhibitHallRepository; private final UserRepository userRepository; private final HomeConverter homeConverter; + private final S3Service s3Service; private final FavoriteExhibitRepository favoriteExhibitRepository; private Set getFavoriteIds(Long userId) { @@ -87,7 +92,7 @@ public FilterResponse getFilterExhibit(ExhibitFilterRequest dto, Pageable pageab // 사용자 맞춤 전시 랜덤 추천 @Transactional - public List getRandomPersonalized(Long userId, PersonalizedRequest request){ + public List getRandomPersonalized(Long userId, PersonalizedRequest request, ImageResizeRequest resize){ if (!userRepository.existsById(userId)) { throw new GeneralException(UserError._USER_NOT_FOUND); @@ -105,6 +110,10 @@ public List getRandomPersonalized(Long userId, PersonalizedReq List results = exhibitRepository.findRandomExhibits(filter); + results.forEach(r -> r.setPosterUrl( + s3Service.buildResizeUrl(r.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + Set favoriteIds = getFavoriteIds(userId); setFavorites(results, favoriteIds); @@ -119,11 +128,15 @@ public List getRandomPersonalized(Long userId, PersonalizedReq } // 이번주 랜덤 전시 추천 - public List getRandomSchedule(ScheduleRandomRequest request, Long userId){ + public List getRandomSchedule(ScheduleRandomRequest request, Long userId, ImageResizeRequest resize){ RandomExhibitRequest filter = homeConverter.from(request); List results = exhibitRepository.findRandomExhibits(filter); + results.forEach(r -> r.setPosterUrl( + s3Service.buildResizeUrl(r.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + Set favoriteIds = getFavoriteIds(userId); setFavorites(results, favoriteIds); @@ -138,12 +151,16 @@ public List getRandomSchedule(ScheduleRandomRequest request, L } // 장르별 전시 랜덤 추천 - public List getRandomGenre(GenreRandomRequest request, Long userId){ + public List getRandomGenre(GenreRandomRequest request, Long userId, ImageResizeRequest resize){ RandomExhibitRequest filter = homeConverter.fromGenre(request); List results = exhibitRepository.findRandomExhibits(filter); + results.forEach(r -> r.setPosterUrl( + s3Service.buildResizeUrl(r.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + Set favoriteIds = getFavoriteIds(userId); setFavorites(results, favoriteIds); @@ -158,12 +175,16 @@ public List getRandomGenre(GenreRandomRequest request, Long us } // 오늘날 전시 랜덤 추천 - public List getRandomToday(TodayRandomRequest request, Long userId){ + public List getRandomToday(TodayRandomRequest request, Long userId, ImageResizeRequest resize){ RandomExhibitRequest filter = homeConverter.fromToday(request); List results = exhibitRepository.findRandomExhibits(filter); + results.forEach(r -> r.setPosterUrl( + s3Service.buildResizeUrl(r.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + Set favoriteIds = getFavoriteIds(userId); setFavorites(results, favoriteIds); diff --git a/src/main/java/org/atdev/artrip/domain/home/web/controller/HomeController.java b/src/main/java/org/atdev/artrip/domain/home/web/controller/HomeController.java index 74a29c7..4ea39f2 100644 --- a/src/main/java/org/atdev/artrip/domain/home/web/controller/HomeController.java +++ b/src/main/java/org/atdev/artrip/domain/home/web/controller/HomeController.java @@ -12,7 +12,9 @@ import org.atdev.artrip.global.apipayload.CommonResponse; import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.HomeError; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.atdev.artrip.global.swagger.ApiErrorResponses; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; @@ -50,11 +52,12 @@ public class HomeController { @PostMapping("/personalized/random") public ResponseEntity>> getRandomPersonalized( @AuthenticationPrincipal UserDetails userDetails, - @Valid @RequestBody PersonalizedRequest requestDto){ + @Valid @RequestBody PersonalizedRequest requestDto, + @ParameterObject ImageResizeRequest resize){ long userId = Long.parseLong(userDetails.getUsername()); - List exhibits= homeService.getRandomPersonalized(userId, requestDto); + List exhibits= homeService.getRandomPersonalized(userId, requestDto, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } @@ -82,10 +85,13 @@ public ResponseEntity>> getRandomPersonali @PostMapping("/schedule") public ResponseEntity>> getRandomSchedule( @Valid @RequestBody ScheduleRandomRequest request, - @AuthenticationPrincipal UserDetails userDetails){ + @AuthenticationPrincipal UserDetails userDetails, + @ParameterObject ImageResizeRequest resize){ + Long userId = Long.parseLong(userDetails.getUsername()); - List exhibits= homeService.getRandomSchedule(request, userId); + + List exhibits= homeService.getRandomSchedule(request, userId, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } @@ -115,10 +121,13 @@ public ResponseEntity>> getRandomSchedule( @PostMapping("/genre/random") public ResponseEntity>> getRandomExhibits( @Valid @RequestBody GenreRandomRequest request, - @AuthenticationPrincipal UserDetails userDetails){ + @AuthenticationPrincipal UserDetails userDetails, + @ParameterObject ImageResizeRequest resize){ + Long userId = Long.parseLong(userDetails.getUsername()); - List exhibits = homeService.getRandomGenre(request, userId); + + List exhibits = homeService.getRandomGenre(request, userId, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } @@ -145,10 +154,12 @@ public ResponseEntity>> getRandomExhibits( @PostMapping("recommend/today") public ResponseEntity>> getTodayRecommendations( @Valid @RequestBody TodayRandomRequest request, - @AuthenticationPrincipal UserDetails userDetails){ + @AuthenticationPrincipal UserDetails userDetails, + @ParameterObject ImageResizeRequest resize){ Long userId = Long.parseLong(userDetails.getUsername()); - List exhibits = homeService.getRandomToday(request, userId); + + List exhibits = homeService.getRandomToday(request, userId, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } diff --git a/src/main/java/org/atdev/artrip/domain/review/service/ReviewService.java b/src/main/java/org/atdev/artrip/domain/review/service/ReviewService.java index 0680d3e..e2b156c 100644 --- a/src/main/java/org/atdev/artrip/domain/review/service/ReviewService.java +++ b/src/main/java/org/atdev/artrip/domain/review/service/ReviewService.java @@ -16,7 +16,8 @@ import org.atdev.artrip.domain.review.web.dto.request.ReviewUpdateRequest; import org.atdev.artrip.global.apipayload.code.status.ReviewError; import org.atdev.artrip.global.apipayload.exception.GeneralException; -import org.atdev.artrip.global.s3.S3Service; +import org.atdev.artrip.global.s3.service.S3Service; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -53,7 +54,7 @@ public ReviewResponse createReview(Long exhibitId, ReviewCreateRequest request, List s3Urls = (images == null || images.isEmpty()) ? new ArrayList<>() - : s3Service.upload(images); + : s3Service.uploadReviews(images); List reviewImages = reviewConverter.toReviewImage(review,s3Urls); @@ -110,7 +111,7 @@ public ReviewResponse updateReview(Long reviewId, ReviewUpdateRequest request, L //이미지 추가 if (images != null && !images.isEmpty()) { - List s3Urls = s3Service.upload(images); + List s3Urls = s3Service.uploadPoster(images); List newReviewImages = reviewConverter.toReviewImage(review, s3Urls); reviewImageRepository.saveAll(newReviewImages); review.getImages().addAll(newReviewImages); @@ -142,7 +143,7 @@ public void deleteReview(Long reviewId,Long userId){ } @Transactional - public ReviewSliceResponse getAllReview(Long userId, Long cursor, int size){ + public ReviewSliceResponse getAllReview(Long userId, Long cursor, int size, ImageResizeRequest resize){ Slice slice; @@ -161,11 +162,15 @@ public ReviewSliceResponse getAllReview(Long userId, Long cursor, int size){ .map(ReviewConverter::toSummary) .toList(); + summaries.forEach(r -> r.setThumbnailUrl( + s3Service.buildResizeUrl(r.getThumbnailUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + return new ReviewSliceResponse(summaries, nextCursor, slice.hasNext()); } @Transactional - public ExhibitReviewSliceResponse getExhibitReview(Long exhibitId, Long cursor, int size){ + public ExhibitReviewSliceResponse getExhibitReview(Long exhibitId, Long cursor, int size, ImageResizeRequest resize){ long totalCount = reviewRepository.countByExhibit_ExhibitId(exhibitId); @@ -186,6 +191,10 @@ public ExhibitReviewSliceResponse getExhibitReview(Long exhibitId, Long cursor, .map(ReviewConverter::toExhibitReviewSummary) .toList(); + summaries.forEach(r -> r.setThumbnailUrl( + s3Service.buildResizeUrl(r.getThumbnailUrl(), resize.getW(), resize.getH(), resize.getF()) + )); + return new ExhibitReviewSliceResponse(summaries, nextCursor, slice.hasNext(),totalCount); } } \ No newline at end of file diff --git a/src/main/java/org/atdev/artrip/domain/review/web/controller/ReviewController.java b/src/main/java/org/atdev/artrip/domain/review/web/controller/ReviewController.java index ce70b75..279daff 100644 --- a/src/main/java/org/atdev/artrip/domain/review/web/controller/ReviewController.java +++ b/src/main/java/org/atdev/artrip/domain/review/web/controller/ReviewController.java @@ -11,7 +11,9 @@ import org.atdev.artrip.global.apipayload.CommonResponse; import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.ReviewError; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.atdev.artrip.global.swagger.ApiErrorResponses; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; @@ -88,11 +90,12 @@ public ResponseEntity> DeleteReview(@PathVariable Long re public ResponseEntity> getAllReview( @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") int size, - @AuthenticationPrincipal UserDetails userDetails) { + @AuthenticationPrincipal UserDetails userDetails, + @ParameterObject ImageResizeRequest resize) { Long userId = Long.valueOf(userDetails.getUsername()); - ReviewSliceResponse response = reviewService.getAllReview(userId, cursor, size); + ReviewSliceResponse response = reviewService.getAllReview(userId, cursor, size, resize); return ResponseEntity.ok(CommonResponse.onSuccess(response)); } @@ -106,9 +109,10 @@ public ResponseEntity> getAllReview( public ResponseEntity> getExhibitReview( @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "10") int size, - @PathVariable Long exhibitId) { + @PathVariable Long exhibitId, + @ParameterObject ImageResizeRequest resize) { - ExhibitReviewSliceResponse response = reviewService.getExhibitReview(exhibitId, cursor, size); + ExhibitReviewSliceResponse response = reviewService.getExhibitReview(exhibitId, cursor, size, resize); return ResponseEntity.ok(CommonResponse.onSuccess(response)); } diff --git a/src/main/java/org/atdev/artrip/domain/user/service/UserService.java b/src/main/java/org/atdev/artrip/domain/user/service/UserService.java index 66f64a3..c62ba8f 100644 --- a/src/main/java/org/atdev/artrip/domain/user/service/UserService.java +++ b/src/main/java/org/atdev/artrip/domain/user/service/UserService.java @@ -14,7 +14,8 @@ import org.atdev.artrip.global.apipayload.code.status.S3Error; import org.atdev.artrip.global.apipayload.code.status.UserError; import org.atdev.artrip.global.apipayload.exception.GeneralException; -import org.atdev.artrip.global.s3.S3Service; +import org.atdev.artrip.global.s3.service.S3Service; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -86,15 +87,16 @@ public String updateProfileImg(Long userId, MultipartFile image){ throw new GeneralException(UserError._PROFILE_IMAGE_NOT_EXIST); } + String oldUrl = user.getProfileImageUrl(); + String newUrl; try { - newUrl = s3Service.upload(image); + newUrl = s3Service.uploadProfile(image); } catch (Exception e) { throw new GeneralException(S3Error._IO_EXCEPTION_UPLOAD_FILE); } user.updateProfileImage(newUrl); - String oldUrl = user.getProfileImageUrl(); if (oldUrl != null && !oldUrl.isBlank()) { try { s3Service.delete(oldUrl); @@ -124,12 +126,14 @@ public void deleteProfileImg(Long userId){ } @Transactional(readOnly = true) - public MypageResponse getMypage(Long userId){ + public MypageResponse getMypage(Long userId, ImageResizeRequest resize){ User user = userRepository.findById(userId) .orElseThrow(()-> new GeneralException(UserError._USER_NOT_FOUND)); - return new MypageResponse(user.getNickName(), user.getProfileImageUrl()); + String profileImage = s3Service.buildResizeUrl(user.getProfileImageUrl(), resize.getW(), resize.getH(), resize.getF()); + + return new MypageResponse(user.getNickName(), profileImage); } diff --git a/src/main/java/org/atdev/artrip/domain/user/web/controller/UserController.java b/src/main/java/org/atdev/artrip/domain/user/web/controller/UserController.java index c325e24..a13795e 100644 --- a/src/main/java/org/atdev/artrip/domain/user/web/controller/UserController.java +++ b/src/main/java/org/atdev/artrip/domain/user/web/controller/UserController.java @@ -12,7 +12,9 @@ import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.KeywordError; import org.atdev.artrip.global.apipayload.code.status.UserError; +import org.atdev.artrip.global.s3.web.dto.request.ImageResizeRequest; import org.atdev.artrip.global.swagger.ApiErrorResponses; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -84,11 +86,12 @@ public ResponseEntity> updateNickname( user = {UserError._USER_NOT_FOUND} ) public ResponseEntity> getMypage( - @AuthenticationPrincipal UserDetails user) { + @AuthenticationPrincipal UserDetails user, + @ParameterObject ImageResizeRequest resize) { Long userId = Long.valueOf(user.getUsername()); - MypageResponse response = userService.getMypage(userId); + MypageResponse response = userService.getMypage(userId, resize); return ResponseEntity.ok(CommonResponse.onSuccess(response)); } diff --git a/src/main/java/org/atdev/artrip/elastic/service/ExhibitIndexService.java b/src/main/java/org/atdev/artrip/elastic/service/ExhibitIndexService.java index 1e411d3..ae82609 100644 --- a/src/main/java/org/atdev/artrip/elastic/service/ExhibitIndexService.java +++ b/src/main/java/org/atdev/artrip/elastic/service/ExhibitIndexService.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -47,8 +48,12 @@ private ExhibitDocument convertToDocument(Exhibit exhibit) { .status(exhibit.getStatus()) .posterUrl(exhibit.getPosterUrl()) .ticketUrl(exhibit.getTicketUrl()) - .latitude(exhibit.getExhibitHall().getLatitude()) - .longitude(exhibit.getExhibitHall().getLongitude()) + .latitude(Optional.ofNullable(exhibit.getExhibitHall()) + .map(hall -> hall.getLatitude()) + .orElse(null)) + .longitude(Optional.ofNullable(exhibit.getExhibitHall()) + .map(hall -> hall.getLongitude()) + .orElse(null)) .keywords(keywordInfos); return builder.build(); diff --git a/src/main/java/org/atdev/artrip/global/apipayload/code/status/S3Error.java b/src/main/java/org/atdev/artrip/global/apipayload/code/status/S3Error.java index a36e15b..1ca6143 100644 --- a/src/main/java/org/atdev/artrip/global/apipayload/code/status/S3Error.java +++ b/src/main/java/org/atdev/artrip/global/apipayload/code/status/S3Error.java @@ -10,12 +10,13 @@ public enum S3Error implements BaseErrorCode { // s3 관련 응답 - _NOT_EXIST_FILE (HttpStatus.NOT_FOUND, "FILE404-NOT_FOUND", "존재하지 않는 파일입니다."), + _NOT_EXIST_FILE(HttpStatus.NOT_FOUND, "FILE404-NOT_FOUND", "존재하지 않는 파일입니다."), _NOT_EXIST_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "FILE400-EXT_MISSING", "확장자가 존재하지 않습니다."), _INVALID_FILE_EXTENSION(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE415-EXT_UNSUPPORTED", "허용되지 않는 확장자입니다."), _INVALID_URL_FORMAT(HttpStatus.BAD_REQUEST, "FILE400-URL_INVALID", "잘못된 URL 형식입니다."), + _FILE_SIZE_EXCEEDED(HttpStatus.PAYLOAD_TOO_LARGE, "FILE413-SIZE_EXCEEDED", "파일 크기는 2MB를 초과할 수 없습니다."), _IO_EXCEPTION_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "FILE500-UPLOAD_IO", "업로드 중 오류가 발생했습니다."), - _IO_EXCEPTION_DELETE_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "FILE500-DELETE_IO", "파일 삭제 중 오류가 발생했습니다."); + _IO_EXCEPTION_DELETE_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "FILE500-DELETE_IO", "파일을 삭제할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/org/atdev/artrip/global/s3/S3Service.java b/src/main/java/org/atdev/artrip/global/s3/service/S3Service.java similarity index 55% rename from src/main/java/org/atdev/artrip/global/s3/S3Service.java rename to src/main/java/org/atdev/artrip/global/s3/service/S3Service.java index 29f8075..c0cca60 100644 --- a/src/main/java/org/atdev/artrip/global/s3/S3Service.java +++ b/src/main/java/org/atdev/artrip/global/s3/service/S3Service.java @@ -1,7 +1,6 @@ -package org.atdev.artrip.global.s3; +package org.atdev.artrip.global.s3.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.S3Error; import org.atdev.artrip.global.apipayload.exception.GeneralException; import org.springframework.beans.factory.annotation.Value; @@ -14,15 +13,15 @@ import software.amazon.awssdk.services.s3.model.ObjectIdentifier; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.io.InputStream; import java.net.URI; import java.net.URL; import java.net.URLDecoder; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.UUID; +import java.util.*; @Slf4j @Service @@ -30,29 +29,52 @@ @Transactional public class S3Service { private final S3Client s3Client; + @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; + @Value("${spring.cloud.aws.cloudfront.domain:}") + private String cloudFrontDomain; + + @Value("${image.upload.max-size:2097152}") + private long maxFileSize; + + @Value("${image.resize.secret-key}") + private String resizeSecretKey; + + private static final String FOLDER_POSTERS = "posters"; + private static final String FOLDER_REVIEWS = "reviews"; + private static final String FOLDER_PROFILES = "profiles"; + + public String uploadPoster(MultipartFile file) { + return uploadToFolder(file, FOLDER_POSTERS); + } + // S3에 저장된 이미지 객체의 public url을 반환 - public List upload(List files) { + public List uploadPoster(List files) { // 각 파일을 업로드하고 url을 리스트로 반환 return files.stream() - .map(this::uploadImage) + .map(this::uploadPoster) .toList(); } - public String upload(MultipartFile file) { - return uploadImage(file); + public String uploadReview(MultipartFile file) { + return uploadToFolder(file, FOLDER_REVIEWS); + } + + public List uploadReviews(List files) { + return files.stream() + .map(this::uploadReview) + .toList(); } - // validateFile메서드를 호출하여 유효성 검증 후 uploadImageToS3메서드에 데이터를 반환하여 S3에 파일 업로드, public url을 받아 서비스 로직에 반환 - private String uploadImage(MultipartFile file) { - validateFile(file.getOriginalFilename()); // 파일 유효성 검증 - return uploadImageToS3(file); // 이미지를 S3에 업로드하고, 저장된 파일의 public url을 서비스 로직에 반환 + public String uploadProfile(MultipartFile file) { + return uploadToFolder(file, FOLDER_PROFILES); } // 파일 유효성 검증 - private void validateFile(String filename) { + private void validateFile(MultipartFile file) { + String filename = file.getOriginalFilename(); // 파일 존재 유무 검증 if (filename == null || filename.isEmpty()) { throw new GeneralException(S3Error._NOT_EXIST_FILE); @@ -66,30 +88,36 @@ private void validateFile(String filename) { // 허용되지 않는 확장자 검증 String fileExtension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); - List allowedExtensions = Arrays.asList("jpg", "png", "jpeg"); + List allowedExtensions = Arrays.asList("jpg", "png", "jpeg", "webp"); if (!allowedExtensions.contains(fileExtension)) { throw new GeneralException(S3Error._INVALID_FILE_EXTENSION); } + + if (file.getSize() > maxFileSize) { + log.warn("파일 크기 초과 : {}bytes (최대 : {}bytes", file.getSize(), maxFileSize); + throw new GeneralException(S3Error._FILE_SIZE_EXCEEDED); + } } // 직접적으로 S3에 업로드 - private String uploadImageToS3(MultipartFile file) { + private String uploadToS3(MultipartFile file, String folder) { // 원본 파일 명 String originalFilename = file.getOriginalFilename(); // 확장자 명 String extension = Objects.requireNonNull(originalFilename).substring(originalFilename.lastIndexOf(".") + 1); // 변경된 파일 - String s3FileName = UUID.randomUUID().toString().substring(0, 10) + "_" + originalFilename; + String s3Key = String.format("%s/%s.%s", folder, UUID.randomUUID().toString().substring(0, 10), extension); // 이미지 파일 -> InputStream 변환 try (InputStream inputStream = file.getInputStream()) { // PutObjectRequest 객체 생성 PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucketName) // 버킷 이름 - .key(s3FileName) // 저장할 파일 이름 + .key(s3Key) // 저장할 파일 이름 .contentType("image/" + extension) // 이미지 MIME 타입 .contentLength(file.getSize()) // 파일 크기 + .cacheControl("public, max-age=31536000") .build(); // S3에 이미지 업로드 s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize())); @@ -99,15 +127,24 @@ private String uploadImageToS3(MultipartFile file) { } // public url 반환 - return s3Client.utilities().getUrl(url -> url.bucket(bucketName).key(s3FileName)).toString(); + return buildImageUrl(s3Key); } // 이미지의 public url을 이용하여 S3에서 해당 이미지를 제거, getKeyFromImageAddress 메서드를 호출하여 삭제에 필요한 key 획득 public void delete(List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + return; + } + List keys = imageUrls.stream() + .filter(url -> url != null && !url.isBlank()) .map(this::getKeyFromImageUrls) .toList(); + if (keys.isEmpty()) { + return; + } + try { // S3에서 파일을 삭제하기 위한 요청 객체 생성 DeleteObjectsRequest deleteObjectsRequest = DeleteObjectsRequest.builder() @@ -128,6 +165,9 @@ public void delete(List imageUrls) { //단일 delete public void delete(String imageUrl) { + if (imageUrl == null || imageUrl.isEmpty()) { + return; + } try { String key = getKeyFromImageUrls(imageUrl); @@ -154,4 +194,65 @@ private String getKeyFromImageUrls(String imageUrl) { } } + private String buildImageUrl(String s3Key) { + if (cloudFrontDomain != null && !cloudFrontDomain.isEmpty()) { + return String.format("https://%s/%s", cloudFrontDomain, s3Key); + } + return s3Client.utilities().getUrl(url -> url.bucket(bucketName).key(s3Key)).toString(); + } + + private String generateSignature(String key, int w, int h, String f, long ts) { + try { + String data = String.format("%s:%d:%d:%s:%d", key, w, h, f, ts); + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(resizeSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (Exception e) { + log.error("서명 생성 실패", e); + throw new GeneralException(S3Error._IO_EXCEPTION_UPLOAD_FILE); + } + } + + public String buildResizeUrl(String originalUrl, Integer width, Integer height, String format) { + if (originalUrl == null || originalUrl.isEmpty()) { + return originalUrl; + } + + if (cloudFrontDomain == null || cloudFrontDomain.isEmpty()) { + return originalUrl; + } + + if (resizeSecretKey == null || resizeSecretKey.isEmpty()) { + return null; + } + + try { + String s3Key = getKeyFromImageUrls(originalUrl); + int w = (width != null && width > 0) ? Math.min(width, 1200) : 1; + int h = (height != null && height > 0) ? Math.min(height, 1200) : 1; + String f = (format != null && !format.isEmpty()) ? format : "webp"; + long ts = System.currentTimeMillis() / 1000; + + String sig = generateSignature(s3Key, w, h, f, ts); + + return String.format("https://%s?key=%s&w=%d&h=%d&f=%s&ts=%d&sig=%s", + cloudFrontDomain, + URLEncoder.encode(s3Key, StandardCharsets.UTF_8), + w, h, f, ts, sig); + } catch (Exception e) { + log.warn("리사이징 URL 생성 실패: {}", originalUrl); + return originalUrl; + } + } + + private String uploadToFolder(MultipartFile file, String folder) { + validateFile(file); + return uploadToS3(file, folder); + } + + + + } \ No newline at end of file diff --git a/src/main/java/org/atdev/artrip/global/s3/S3Controller.java b/src/main/java/org/atdev/artrip/global/s3/web/controller/S3Controller.java similarity index 85% rename from src/main/java/org/atdev/artrip/global/s3/S3Controller.java rename to src/main/java/org/atdev/artrip/global/s3/web/controller/S3Controller.java index 861f3a5..d8a55a0 100644 --- a/src/main/java/org/atdev/artrip/global/s3/S3Controller.java +++ b/src/main/java/org/atdev/artrip/global/s3/web/controller/S3Controller.java @@ -1,9 +1,11 @@ -package org.atdev.artrip.global.s3; +package org.atdev.artrip.global.s3.web.controller; import lombok.RequiredArgsConstructor; import org.atdev.artrip.global.apipayload.CommonResponse; import org.atdev.artrip.global.apipayload.code.status.CommonError; import org.atdev.artrip.global.apipayload.code.status.S3Error; +import org.atdev.artrip.global.s3.service.S3Service; +import org.atdev.artrip.global.s3.web.dto.request.ImageDeleteRequest; import org.atdev.artrip.global.swagger.ApiErrorResponses; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,7 +24,7 @@ public class S3Controller { s3 = {S3Error._NOT_EXIST_FILE, S3Error._NOT_EXIST_FILE_EXTENSION, S3Error._IO_EXCEPTION_UPLOAD_FILE, S3Error._INVALID_URL_FORMAT} ) public CommonResponse> s3Upload(@RequestPart(value = "image") List multipartFile) { - List upload = s3Service.upload(multipartFile); + List upload = s3Service.uploadPoster(multipartFile); return CommonResponse.onSuccess(upload); } diff --git a/src/main/java/org/atdev/artrip/global/s3/ImageDeleteRequest.java b/src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageDeleteRequest.java similarity index 82% rename from src/main/java/org/atdev/artrip/global/s3/ImageDeleteRequest.java rename to src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageDeleteRequest.java index 625e4c7..34ab7b3 100644 --- a/src/main/java/org/atdev/artrip/global/s3/ImageDeleteRequest.java +++ b/src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageDeleteRequest.java @@ -1,4 +1,4 @@ -package org.atdev.artrip.global.s3; +package org.atdev.artrip.global.s3.web.dto.request; import lombok.AccessLevel; diff --git a/src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageResizeRequest.java b/src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageResizeRequest.java new file mode 100644 index 0000000..ed4ed38 --- /dev/null +++ b/src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageResizeRequest.java @@ -0,0 +1,23 @@ +package org.atdev.artrip.global.s3.web.dto.request; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ImageResizeRequest { + + @Schema(description = "width", defaultValue = "100") + private Integer w; + + @Schema(description = "height", defaultValue = "100") + private Integer h; + + @Schema(defaultValue = "webp") + private String f = "webp"; + +} diff --git a/src/main/java/org/atdev/artrip/global/s3/web/dto/response/ImageResponse.java b/src/main/java/org/atdev/artrip/global/s3/web/dto/response/ImageResponse.java new file mode 100644 index 0000000..6916989 --- /dev/null +++ b/src/main/java/org/atdev/artrip/global/s3/web/dto/response/ImageResponse.java @@ -0,0 +1,19 @@ +package org.atdev.artrip.global.s3.web.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ImageResponse { + + private String originalUrl; + private String posterUrl; + + public static ImageResponse of(String originalUrl, String posterUrl) { + return ImageResponse.builder() + .originalUrl(originalUrl) + .posterUrl(posterUrl) + .build(); + } +}