From 082151f96f0365a566f6019db60120a9d9ba1116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=89=E1=85=A5=E1=86=AB=E1=84=92?= =?UTF-8?q?=E1=85=A9?= Date: Thu, 25 Dec 2025 19:39:09 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT]=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80,=20=EC=A0=84=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EA=B8=B0=EB=8A=A5=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/service/ExhibitService.java | 9 +- .../web/controller/ExhibitController.java | 8 +- .../repository/ExhibitHallRepository.java | 2 +- .../domain/home/converter/HomeConverter.java | 4 +- .../domain/home/service/HomeService.java | 29 +++- .../home/web/controller/HomeController.java | 22 ++- .../domain/review/service/ReviewService.java | 19 ++- .../web/controller/ReviewController.java | 12 +- .../domain/user/service/UserService.java | 14 +- .../user/web/controller/UserController.java | 7 +- .../elastic/service/ExhibitIndexService.java | 9 +- .../apipayload/code/status/S3Error.java | 5 +- .../global/s3/{ => service}/S3Service.java | 141 +++++++++++++++--- .../s3/{ => web/controller}/S3Controller.java | 6 +- .../dto/request}/ImageDeleteRequest.java | 2 +- .../web/dto/request/ImageResizeRequest.java | 23 +++ .../s3/web/dto/response/ImageResponse.java | 19 +++ 17 files changed, 269 insertions(+), 62 deletions(-) rename src/main/java/org/atdev/artrip/global/s3/{ => service}/S3Service.java (55%) rename src/main/java/org/atdev/artrip/global/s3/{ => web/controller}/S3Controller.java (85%) rename src/main/java/org/atdev/artrip/global/s3/{ => web/dto/request}/ImageDeleteRequest.java (82%) create mode 100644 src/main/java/org/atdev/artrip/global/s3/web/dto/request/ImageResizeRequest.java create mode 100644 src/main/java/org/atdev/artrip/global/s3/web/dto/response/ImageResponse.java 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 e67c64b..a10cfa7 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 @@ -7,6 +7,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 @@ -15,14 +17,17 @@ public class ExhibitService { private final ExhibitRepository exhibitRepository; private final HomeConverter homeConverter; + private final S3Service s3Service; - public ExhibitDetailResponse getExhibitDetail(Long exhibitId) { + public ExhibitDetailResponse getExhibitDetail(Long exhibitId, ImageResizeRequest resize) { Exhibit exhibit = exhibitRepository.findById(exhibitId) .orElseThrow(() -> new GeneralException(ExhibitError._EXHIBIT_NOT_FOUND)); - return homeConverter.toHomeExhibitResponse(exhibit); + String resizedPosterUrl = s3Service.buildResizeUrl(exhibit.getPosterUrl(), resize.getW(), resize.getH(), resize.getF()); + + return homeConverter.toHomeExhibitResponse(exhibit, 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 6284cf6..b90c9d6 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 @@ -10,7 +10,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; @@ -45,9 +47,11 @@ public ResponseEntity>> getGenres(){ ) @GetMapping("/{id}") public ResponseEntity> getExhibit( - @PathVariable Long id){ + @PathVariable Long id, + @ParameterObject ImageResizeRequest resize + ){ - ExhibitDetailResponse exhibit= exhibitService.getExhibitDetail(id); + ExhibitDetailResponse exhibit= exhibitService.getExhibitDetail(id, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibit)); } diff --git a/src/main/java/org/atdev/artrip/domain/exhibitHall/repository/ExhibitHallRepository.java b/src/main/java/org/atdev/artrip/domain/exhibitHall/repository/ExhibitHallRepository.java index 064d3a2..e5130a9 100644 --- a/src/main/java/org/atdev/artrip/domain/exhibitHall/repository/ExhibitHallRepository.java +++ b/src/main/java/org/atdev/artrip/domain/exhibitHall/repository/ExhibitHallRepository.java @@ -17,7 +17,7 @@ public interface ExhibitHallRepository extends JpaRepository @Query("SELECT DISTINCT e.country FROM ExhibitHall e WHERE e.country <> '한국'") List findAllOverseasCountries(); - @Query("SELECT DISTINCT e.region FROM ExhibitHall e WHERE e.country = '한국'") + @Query("SELECT DISTINCT e.region FROM ExhibitHall e WHERE e.country IN ('한국', '대한민국')") List findAllDomesticRegions(); 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 b0dc1f7..914619f 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 @@ -48,7 +48,7 @@ public HomeListResponse toHomeExhibitListResponse(Exhibit exhibit){ .build(); } - public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit) { + public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit, String resizePosterUrl) { var hall = exhibit.getExhibitHall(); String period = exhibit.getStartDate().format(formatter) + " - " + exhibit.getEndDate().format(formatter); @@ -60,7 +60,7 @@ public ExhibitDetailResponse toHomeExhibitResponse(Exhibit exhibit) { .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 9a29c5c..c0b27f4 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; @@ -17,6 +18,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; @@ -26,6 +29,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class HomeService { private final ExhibitRepository exhibitRepository; @@ -33,6 +37,7 @@ public class HomeService { private final ExhibitHallRepository exhibitHallRepository; private final UserRepository userRepository; private final HomeConverter homeConverter; + private final S3Service s3Service; @@ -71,7 +76,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); @@ -89,6 +94,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()) + )); + adjustLocationFields( results, request.getIsDomestic(), @@ -100,11 +109,15 @@ public List getRandomPersonalized(Long userId, PersonalizedReq } // 이번주 랜덤 전시 추천 - public List getRandomSchedule(ScheduleRandomRequest request){ + public List getRandomSchedule(ScheduleRandomRequest request, 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()) + )); + adjustLocationFields( results, request.getIsDomestic(), @@ -116,12 +129,16 @@ public List getRandomSchedule(ScheduleRandomRequest request){ } // 장르별 전시 랜덤 추천 - public List getRandomGenre(GenreRandomRequest request){ + public List getRandomGenre(GenreRandomRequest request, 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()) + )); + adjustLocationFields( results, request.getIsDomestic(), @@ -133,12 +150,16 @@ public List getRandomGenre(GenreRandomRequest request){ } // 오늘날 전시 랜덤 추천 - public List getRandomToday(TodayRandomRequest request){ + public List getRandomToday(TodayRandomRequest request, 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()) + )); + adjustLocationFields( results, request.getIsDomestic(), 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 109d67b..ba39f50 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)); } @@ -81,9 +84,10 @@ public ResponseEntity>> getRandomPersonali ) @PostMapping("/schedule") public ResponseEntity>> getRandomSchedule( - @Valid @RequestBody ScheduleRandomRequest request){ + @Valid @RequestBody ScheduleRandomRequest request, + @ParameterObject ImageResizeRequest resize){ - List exhibits= homeService.getRandomSchedule(request); + List exhibits= homeService.getRandomSchedule(request, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } @@ -112,9 +116,10 @@ public ResponseEntity>> getRandomSchedule( ) @PostMapping("/genre/random") public ResponseEntity>> getRandomExhibits( - @Valid @RequestBody GenreRandomRequest request){ + @Valid @RequestBody GenreRandomRequest request, + @ParameterObject ImageResizeRequest resize){ - List exhibits = homeService.getRandomGenre(request); + List exhibits = homeService.getRandomGenre(request, resize); return ResponseEntity.ok(CommonResponse.onSuccess(exhibits)); } @@ -140,9 +145,10 @@ public ResponseEntity>> getRandomExhibits( ) @PostMapping("recommend/today") public ResponseEntity>> getTodayRecommendations( - @Valid @RequestBody TodayRandomRequest request){ + @Valid @RequestBody TodayRandomRequest request, + @ParameterObject ImageResizeRequest resize){ - List exhibits = homeService.getRandomToday(request); + List exhibits = homeService.getRandomToday(request, 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 3b5eca6..535f160 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 98eb47e..8588019 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; @@ -111,15 +112,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); @@ -149,12 +151,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 1de7093..9ac2277 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 @@ -11,7 +11,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; @@ -99,11 +101,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(); + } +}