diff --git a/src/main/java/com/sumte/apiPayload/code/error/ImageErrorCode.java b/src/main/java/com/sumte/apiPayload/code/error/ImageErrorCode.java new file mode 100644 index 0000000..00c6e66 --- /dev/null +++ b/src/main/java/com/sumte/apiPayload/code/error/ImageErrorCode.java @@ -0,0 +1,24 @@ +package com.sumte.apiPayload.code.error; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ImageErrorCode implements ErrorCode { + IMAGE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "IMAGE400", "해당 이미지가 이미 등록되어 있습니다."), + + GUESTHOUSE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 게스트하우스를 찾을 수 없습니다."), + + ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 룸을 찾을 수 없습니다."), + + REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당 리뷰를 찾을 수 없습니다."), + + INVALID_OWNER_TYPE(HttpStatus.BAD_REQUEST, "IMAGE400", "지원하지 않는 이미지 소유자 타입입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/sumte/guesthouse/controller/GuesthouseController.java b/src/main/java/com/sumte/guesthouse/controller/GuesthouseController.java index b46bebe..942d69e 100644 --- a/src/main/java/com/sumte/guesthouse/controller/GuesthouseController.java +++ b/src/main/java/com/sumte/guesthouse/controller/GuesthouseController.java @@ -20,6 +20,7 @@ import com.sumte.apiPayload.ApiResponse; import com.sumte.apiPayload.exception.annotation.CheckPage; import com.sumte.apiPayload.exception.annotation.CheckPageSize; +import com.sumte.guesthouse.dto.GuesthouseDetailDTO; import com.sumte.guesthouse.dto.GuesthousePreviewDTO; import com.sumte.guesthouse.dto.GuesthouseRequestDTO; import com.sumte.guesthouse.dto.GuesthouseResponseDTO; @@ -35,7 +36,7 @@ import lombok.RequiredArgsConstructor; @RestController -@Tag(name = "게스트 하우스 관련 api", description = "게스트하우스 생성/수정/조회/삭제 api 입니다.") +@Tag(name = "게스트 하우스 api", description = "게스트하우스 생성/수정/조회/삭제 api 입니다.") @RequiredArgsConstructor @RequestMapping("/guesthouse") public class GuesthouseController { @@ -69,11 +70,12 @@ public ApiResponse deleteGuesthouse( @Parameters({ @Parameter(name = "guesthouseId", description = "게스트하우스 아이디를 넘겨주세요") }) - public ApiResponse updateGuesthouse( + public ResponseEntity updateGuesthouse( @PathVariable(name = "guesthouseId") Long guesthouseId, @RequestBody @Valid GuesthouseRequestDTO.Update dto) { guesthouseCommandService.updateGuesthouse(guesthouseId, dto); - return ApiResponse.successWithNoData(); + + return ResponseEntity.ok(guesthouseId); } // @Operation(summary = "홈 화면 게스트하우스 목록 조회 (광고 우선)", description = "게스트하우스 목록을 보여줍니다") @@ -85,6 +87,7 @@ public ApiResponse updateGuesthouse( // } @GetMapping("/home") + @Operation(summary = "홈 화면 조회", description = "홈 화면에 출력할 홈 화면 전용 게스트하우스 조회 API입니다.") public ResponseEntity>> getGuesthousesForHome( @ParameterObject @PageableDefault(size = 10) Pageable pageable) { @@ -112,10 +115,10 @@ public ApiResponse deactivateAdvertisement(@PathVariable Long guesthouseId @Parameters({ @Parameter(name = "guesthouseId", description = "게스트하우스 아이디를 넘겨주세요.") }) - public ResponseEntity> getRoom( + public ResponseEntity> getRoom( @PathVariable Long guesthouseId ) { - GuesthouseResponseDTO.GetHouseResponse result = guesthouseQueryService.getHouseById(guesthouseId); + GuesthouseDetailDTO result = guesthouseQueryService.getHouseById(guesthouseId); return ResponseEntity.ok(ApiResponse.success(result)); } diff --git a/src/main/java/com/sumte/guesthouse/controller/OptionServiceController.java b/src/main/java/com/sumte/guesthouse/controller/OptionServiceController.java index 6be9b27..f75fff1 100644 --- a/src/main/java/com/sumte/guesthouse/controller/OptionServiceController.java +++ b/src/main/java/com/sumte/guesthouse/controller/OptionServiceController.java @@ -13,7 +13,7 @@ import lombok.RequiredArgsConstructor; @RestController -@Tag(name = "부가서비스 조회 api", description = "부가서비스 리스트를 조회하는 api 입니다.") +@Tag(name = "게스트 하우스 api", description = "게스트하우스 생성/수정/조회/삭제 api 입니다.") @RequiredArgsConstructor @RequestMapping("/option") public class OptionServiceController { diff --git a/src/main/java/com/sumte/guesthouse/controller/TargetAudienceController.java b/src/main/java/com/sumte/guesthouse/controller/TargetAudienceController.java index 1e2a0bf..b45be11 100644 --- a/src/main/java/com/sumte/guesthouse/controller/TargetAudienceController.java +++ b/src/main/java/com/sumte/guesthouse/controller/TargetAudienceController.java @@ -13,7 +13,7 @@ import lombok.RequiredArgsConstructor; @RestController -@Tag(name = "이용대상 조회 api", description = "게스트하우스의 이용대상을 조회하는 api 입니다.") +@Tag(name = "게스트 하우스 api", description = "게스트하우스 생성/수정/조회/삭제 api 입니다.") @RequiredArgsConstructor @RequestMapping("/target") public class TargetAudienceController { diff --git a/src/main/java/com/sumte/guesthouse/converter/GuesthouseConverter.java b/src/main/java/com/sumte/guesthouse/converter/GuesthouseConverter.java index 9f5ce62..eaccc59 100644 --- a/src/main/java/com/sumte/guesthouse/converter/GuesthouseConverter.java +++ b/src/main/java/com/sumte/guesthouse/converter/GuesthouseConverter.java @@ -49,7 +49,6 @@ public GuesthouseResponseDTO.HomeSummary toHomeSummary( .guestHouseId(guesthouse.getId()) .name(guesthouse.getName()) .addressRegion(guesthouse.getAddressRegion()) - .imageUrl(guesthouse.getImageUrl()) .averageScore(avgScore) .reviewCount(reviewCount) .checkInTime(checkInTime) diff --git a/src/main/java/com/sumte/guesthouse/dto/GuesthouseDetailDTO.java b/src/main/java/com/sumte/guesthouse/dto/GuesthouseDetailDTO.java new file mode 100644 index 0000000..a6e4b5e --- /dev/null +++ b/src/main/java/com/sumte/guesthouse/dto/GuesthouseDetailDTO.java @@ -0,0 +1,28 @@ +package com.sumte.guesthouse.dto; + +import java.util.List; + +import com.sumte.guesthouse.entity.AdType; +import com.sumte.room.dto.RoomResponseDTO; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GuesthouseDetailDTO { + private Long id; + private String name; + private String addressRegion; + private String addressDetail; + private String information; + private AdType advertisement; + private List optionServices; + private List targetAudience; + private List rooms; + private List imageUrls; // 모든 이미지 URL 리스트 +} diff --git a/src/main/java/com/sumte/guesthouse/dto/GuesthousePreviewDTO.java b/src/main/java/com/sumte/guesthouse/dto/GuesthousePreviewDTO.java index b99abc2..e0844a4 100644 --- a/src/main/java/com/sumte/guesthouse/dto/GuesthousePreviewDTO.java +++ b/src/main/java/com/sumte/guesthouse/dto/GuesthousePreviewDTO.java @@ -15,16 +15,9 @@ public class GuesthousePreviewDTO { private Long id; private String name; private Double averageScore; - private Long reviewCount; + private Integer reviewCount; private Long lowerPrice; private String addressRegion; private LocalTime checkinTime; - - public void setAverageScore(Double averageScore) { - this.averageScore = averageScore; - } - - public void setReviewCount(Long reviewCount) { - this.reviewCount = reviewCount; - } + private String imageUrl; } diff --git a/src/main/java/com/sumte/guesthouse/dto/GuesthouseResponseDTO.java b/src/main/java/com/sumte/guesthouse/dto/GuesthouseResponseDTO.java index 3df64ac..1303db6 100644 --- a/src/main/java/com/sumte/guesthouse/dto/GuesthouseResponseDTO.java +++ b/src/main/java/com/sumte/guesthouse/dto/GuesthouseResponseDTO.java @@ -60,7 +60,6 @@ public static class GetHouseResponse { List optionServices; List targetAudience; List rooms; - } @Builder diff --git a/src/main/java/com/sumte/guesthouse/entity/Guesthouse.java b/src/main/java/com/sumte/guesthouse/entity/Guesthouse.java index 5d0692f..946a13c 100644 --- a/src/main/java/com/sumte/guesthouse/entity/Guesthouse.java +++ b/src/main/java/com/sumte/guesthouse/entity/Guesthouse.java @@ -33,8 +33,6 @@ public class Guesthouse extends BaseTimeEntity { @Enumerated(EnumType.STRING) private AdType advertisement; - private String imageUrl; - private String information; @OneToMany(mappedBy = "guesthouse", cascade = CascadeType.ALL, orphanRemoval = true) @@ -45,7 +43,6 @@ public static Guesthouse createByRegisterDTO(GuesthouseRequestDTO.Register dto) guesthouse.name = dto.getName(); guesthouse.addressRegion = dto.getAddressRegion(); guesthouse.addressDetail = dto.getAddressDetail(); - guesthouse.imageUrl = dto.getImageUrl(); guesthouse.information = dto.getInformation(); guesthouse.advertisement = AdType.NON_AD; return guesthouse; @@ -63,10 +60,6 @@ public void setAddressDetail(String addressDetail) { this.addressDetail = addressDetail; } - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - public void setInformation(String information) { this.information = information; } diff --git a/src/main/java/com/sumte/guesthouse/service/GuesthouseCommandServiceImpl.java b/src/main/java/com/sumte/guesthouse/service/GuesthouseCommandServiceImpl.java index 04512f2..fb2a13e 100644 --- a/src/main/java/com/sumte/guesthouse/service/GuesthouseCommandServiceImpl.java +++ b/src/main/java/com/sumte/guesthouse/service/GuesthouseCommandServiceImpl.java @@ -92,9 +92,6 @@ public GuesthouseResponseDTO.Update updateGuesthouse(Long guesthouseId, Guesthou if (dto.getAddressDetail() != null) { guesthouse.setAddressDetail(dto.getAddressDetail()); } - if (dto.getImageUrl() != null) { - guesthouse.setImageUrl(dto.getImageUrl()); - } if (dto.getInformation() != null) { guesthouse.setInformation(dto.getInformation()); } diff --git a/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryService.java b/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryService.java index 8ccbbca..b7c61e1 100644 --- a/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryService.java +++ b/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryService.java @@ -4,12 +4,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import com.sumte.guesthouse.dto.GuesthouseDetailDTO; import com.sumte.guesthouse.dto.GuesthousePreviewDTO; import com.sumte.guesthouse.dto.GuesthouseResponseDTO; import com.sumte.guesthouse.dto.GuesthouseSearchRequestDTO; public interface GuesthouseQueryService { - GuesthouseResponseDTO.GetHouseResponse getHouseById(Long id); + GuesthouseDetailDTO getHouseById(Long id); Page getFilteredGuesthouse(GuesthouseSearchRequestDTO dto, Pageable pageable); diff --git a/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryServiceImpl.java b/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryServiceImpl.java index f8040d8..a0aa4ff 100644 --- a/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryServiceImpl.java +++ b/src/main/java/com/sumte/guesthouse/service/GuesthouseQueryServiceImpl.java @@ -1,27 +1,37 @@ package com.sumte.guesthouse.service; +import java.time.LocalTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; import com.sumte.apiPayload.code.error.CommonErrorCode; import com.sumte.apiPayload.exception.SumteException; -import com.sumte.guesthouse.converter.GuesthouseConverter; +import com.sumte.guesthouse.dto.GuesthouseDetailDTO; import com.sumte.guesthouse.dto.GuesthousePreviewDTO; import com.sumte.guesthouse.dto.GuesthouseResponseDTO; import com.sumte.guesthouse.dto.GuesthouseSearchRequestDTO; +import com.sumte.guesthouse.entity.AdType; import com.sumte.guesthouse.entity.Guesthouse; import com.sumte.guesthouse.repository.GuesthouseOptionServicesRepository; import com.sumte.guesthouse.repository.GuesthouseRepository; import com.sumte.guesthouse.repository.GuesthouseRepositoryCustom; import com.sumte.guesthouse.repository.GuesthouseTargetAudienceRepository; +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.repository.ImageRepository; import com.sumte.review.repository.ReviewRepository; import com.sumte.room.dto.RoomResponseDTO; +import com.sumte.room.entity.Room; import com.sumte.room.repository.RoomRepository; import jakarta.transaction.Transactional; @@ -34,24 +44,55 @@ public class GuesthouseQueryServiceImpl implements GuesthouseQueryService { private final GuesthouseRepository guesthouseRepository; private final RoomRepository roomRepository; private final ReviewRepository reviewRepository; - private final GuesthouseConverter guesthouseConverter; private final GuesthouseTargetAudienceRepository guesthouseTargetAudienceRepository; private final GuesthouseOptionServicesRepository guesthouseOptionServicesRepository; private final GuesthouseRepositoryCustom guesthouseRepositoryCustom; + private final ImageRepository imageRepository; @Override @Transactional - public GuesthouseResponseDTO.GetHouseResponse getHouseById(Long id) { + public GuesthouseDetailDTO getHouseById(Long guesthouseId) { + // 1) 기본 정보 로드 + Guesthouse gh = guesthouseRepository.findById(guesthouseId) + .orElseThrow(() -> new SumteException(CommonErrorCode.NOT_EXIST)); - Guesthouse guesthouse = guesthouseRepository.findById(id).orElseThrow( - () -> new SumteException(CommonErrorCode.NOT_EXIST) - ); + // 2) 옵션·타깃 + List targetAudiences = guesthouseTargetAudienceRepository + .findTargetAudienceNamesByGuesthouseId(guesthouseId); + List optionServices = guesthouseOptionServicesRepository + .findTargetAudienceNamesByGuesthouseId(guesthouseId); + + // 3) **게스트하우스가 가진 모든 이미지** 조회 & URL 리스트 변환 + List ghImageUrls = imageRepository + .findByOwnerTypeAndOwnerIdOrderBySortOrderAsc(OwnerType.GUESTHOUSE, guesthouseId) + .stream() + .map(Image::getUrl) + .toList(); + + // 4) 각 Room 정보 + 첫 번째 이미지 + List rooms = gh.getRooms(); + List roomIds = rooms.stream().map(Room::getId).toList(); + + // 4-a) 객실 이미지 일괄 조회 + List roomImages = imageRepository + .findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc(OwnerType.ROOM, roomIds); - List targetAudiences = guesthouseTargetAudienceRepository.findTargetAudienceNamesByGuesthouseId(id); - List optionServices = guesthouseOptionServicesRepository.findTargetAudienceNamesByGuesthouseId(id); + // 4-b) 방 ID 별 첫 장 URL + Map firstImageByRoom = roomImages.stream() + .collect(Collectors.groupingBy( + Image::getOwnerId, + LinkedHashMap::new, + Collectors.mapping(Image::getUrl, Collectors.toList()) + )) + .entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().get(0) + )); - List roomDtos = guesthouse.getRooms().stream() - .map(room -> RoomResponseDTO.GetRoomResponse.builder() + // 4-c) RoomResponseDTO 리스트 생성 + List roomDtos = rooms.stream() + .map(room -> RoomResponseDTO.GetPreviewRoomByGuesthouseResponse.builder() .id(room.getId()) .name(room.getName()) .content(room.getContents()) @@ -60,40 +101,157 @@ public GuesthouseResponseDTO.GetHouseResponse getHouseById(Long id) { .checkout(room.getCheckout()) .standardCount(room.getStandardCount()) .totalCount(room.getTotalCount()) - .imageUrl(room.getImageUrl()) + .imageUrl(firstImageByRoom.get(room.getId())) // 각 방 첫 장 이미지 .build()) - .collect(Collectors.toList()); - - return GuesthouseResponseDTO.GetHouseResponse.builder() - .id(guesthouse.getId()) - .name(guesthouse.getName()) - .addressDetail(guesthouse.getAddressDetail()) - .addressRegion(guesthouse.getAddressRegion()) - .information(guesthouse.getInformation()) - .imageUrl(guesthouse.getImageUrl()) - .targetAudience(targetAudiences) + .toList(); + + // 5) 최종 DTO 조립 + return GuesthouseDetailDTO.builder() + .id(gh.getId()) + .name(gh.getName()) + .addressRegion(gh.getAddressRegion()) + .addressDetail(gh.getAddressDetail()) + .information(gh.getInformation()) + .advertisement(gh.getAdvertisement()) .optionServices(optionServices) - .rooms(roomDtos) + .targetAudience(targetAudiences) + .imageUrls(ghImageUrls) // 모든 게스트하우스 이미지 + .rooms(roomDtos) // 각 방 + 첫 장 이미지 .build(); } @Override @Transactional public Slice getGuesthousesForHome(Pageable pageable) { - Slice guesthouses = guesthouseRepository.findAllOrderedForHome(pageable); - - return guesthouses.map(gh -> guesthouseConverter.toHomeSummary( - gh, - Optional.ofNullable(reviewRepository.findAverageScoreByGuesthouseId(gh.getId())).orElse(0.0), - reviewRepository.countByGuesthouseId(gh.getId()), - Optional.ofNullable(roomRepository.findEarliestCheckinByGuesthouseId(gh.getId())).orElse("00:00"), - Optional.ofNullable(roomRepository.findMinPriceByGuesthouseId(gh.getId())).orElse(0L) - )); + // 1) 페이징 혹은 슬라이스 조회 + Slice slice = guesthouseRepository.findAllOrderedForHome(pageable); + List ghList = slice.getContent(); + if (ghList.isEmpty()) { + return new SliceImpl<>(List.of(), pageable, false); + } + + // 2) ID 리스트 & 이미지 조회 + List ghIds = ghList.stream().map(Guesthouse::getId).toList(); + List images = imageRepository + .findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc( + OwnerType.GUESTHOUSE, ghIds + ); + Map firstImageByGh = images.stream() + .collect(Collectors.groupingBy( + Image::getOwnerId, + LinkedHashMap::new, + Collectors.mapping(Image::getUrl, Collectors.toList()) + )) + .entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().get(0) + )); + + // 3) DTO 변환 + List summaries = ghList.stream() + .map(gh -> GuesthouseResponseDTO.HomeSummary.builder() + .guestHouseId(gh.getId()) + .name(gh.getName()) + .addressRegion(gh.getAddressRegion()) + .imageUrl(firstImageByGh.getOrDefault(gh.getId(), null)) + .averageScore( + Optional.ofNullable( + reviewRepository.findAverageScoreByGuesthouseId(gh.getId()) + ).orElse(0.0) + ) + .reviewCount( + reviewRepository.countByGuesthouseId(gh.getId()) + ) + .checkInTime( + Optional.ofNullable( + roomRepository.findEarliestCheckinByGuesthouseId(gh.getId()) + ).orElse("00:00") + ) + .minPrice( + Optional.ofNullable( + roomRepository.findMinPriceByGuesthouseId(gh.getId()) + ).orElse(0L) + ) + .isAd(gh.getAdvertisement() == AdType.AD) + .build() + ) + .toList(); + + // 4) SliceImpl 생성해 반환 + return new SliceImpl<>( + summaries, + pageable, + slice.hasNext() + ); } @Override @Transactional public Page getFilteredGuesthouse(GuesthouseSearchRequestDTO dto, Pageable pageable) { - return guesthouseRepositoryCustom.searchFiltered(dto, pageable); + // 1) 필터링된 게스트하우스 페이징 조회 + Page page = guesthouseRepositoryCustom.searchFiltered(dto, pageable); + List ghList = page.getContent(); + if (ghList.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + // 2) 조회된 게스트하우스 ID들 + List ghIds = ghList.stream() + .map(GuesthousePreviewDTO::getId) + .toList(); + + // 3) 이미지 일괄 조회 (N+1 방지) + List images = imageRepository + .findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc( + OwnerType.GUESTHOUSE, ghIds + ); + + // 4) ownerId 별로 첫 번째 URL만 뽑아서 맵으로 저장 + Map firstImageByGh = images.stream() + .collect(Collectors.groupingBy( + Image::getOwnerId, + LinkedHashMap::new, // key 순서 보장(Optional) + Collectors.mapping(Image::getUrl, Collectors.toList()) + )) + .entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().get(0) // 리스트의 첫 번째 URL + )); + + // 5) DTO 변환 + List dtos = ghList.stream() + .map(gh -> GuesthousePreviewDTO.builder() + .id(gh.getId()) + .name(gh.getName()) + .averageScore( + Optional.ofNullable( + reviewRepository.findAverageScoreByGuesthouseId(gh.getId()) + ).orElse(0.0) + ) + .reviewCount( + reviewRepository.countByGuesthouseId(gh.getId()) + ) + .lowerPrice( + Optional.ofNullable( + roomRepository.findMinPriceByGuesthouseId(gh.getId()) + ).orElse(0L) + ) + .addressRegion(gh.getAddressRegion()) + .checkinTime( + LocalTime.parse(Optional.ofNullable( + roomRepository.findEarliestCheckinByGuesthouseId(gh.getId()) + ).orElse("00:00")) + ) + .imageUrl( + firstImageByGh.getOrDefault(gh.getId(), null) + ) + .build() + ) + .toList(); + + // 6) PageImpl 으로 감싸서 반환 + return new PageImpl<>(dtos, pageable, page.getTotalElements()); } } \ No newline at end of file diff --git a/src/main/java/com/sumte/image/controller/ImageController.java b/src/main/java/com/sumte/image/controller/ImageController.java new file mode 100644 index 0000000..df48752 --- /dev/null +++ b/src/main/java/com/sumte/image/controller/ImageController.java @@ -0,0 +1,174 @@ +package com.sumte.image.controller; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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.RestController; + +import com.sumte.image.dto.ImageRequestDTO; +import com.sumte.image.dto.ImageResponseDTO; +import com.sumte.image.dto.ReplaceImageRequestDTO; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.service.ImageService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/images") +@RequiredArgsConstructor +@Tag(name = "이미지 API", description = "이미지 메타데이터 저장·조회·교체 API 및 S3 PresignedUrl 발급 API") +public class ImageController { + private final ImageService imageService; + + @Operation(summary = "이미지 일괄 저장", + description = """ + - 여러 이미지 메타데이터를 한 번에 저장합니다. + - 이미지가 등록되는 파트에 대한 정보 OwnerType(GUESTHOUSE, ROOM, REVIEW)과 + OwnerId(해당 파트의 ID)를 함께 전달해야 합니다. + - 요청 리스트 순서대로 서버에서 sortOrder(이미지 순서)가 1부터 자동 부여됩니다. + """) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "등록할 이미지 리스트를 전달합니다. 서버가 순서를 1부터 자동 부여합니다.", + required = true, + content = @Content( + mediaType = "application/json", + array = @ArraySchema( + schema = @Schema(implementation = ImageRequestDTO.class) + ), + examples = { + @ExampleObject( + name = "Image Batch Upload Example", + value = """ + [ + { + "ownerType": "GUESTHOUSE", + "ownerId": 1, + "url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte1.png" + }, + { + "ownerType": "GUESTHOUSE", + "ownerId": 1, + "url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte2.png" + }, + { + "ownerType": "GUESTHOUSE", + "ownerId": 1, + "url": "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte3.png" + } + ] + """ + ) + } + ) + ) + @PostMapping + public ResponseEntity> saveImagesBatch( + + @Valid @RequestBody List imageRequestDTOS) { + + var savedImageList = imageService.saveAllImages(imageRequestDTOS); + var imageResponseDTOS = savedImageList.stream() + .map(img -> new ImageResponseDTO( + img.getId(), img.getUrl(), img.getSortOrder(), img.getOwnerType(), img.getOwnerId())) + .collect(Collectors.toList()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(imageResponseDTOS); + } + + @Operation( + summary = "이미지 전체 교체", + description = """ + 주어진 ownerType/ownerId 에 등록된 모든 이미지를 삭제하고, + 요청 리스트 순서대로 새 이미지를 1부터 순차 저장합니다. + - S3 객체도 함께 삭제됩니다. + """) + @PutMapping("/{ownerType}/{ownerId}") + public ResponseEntity> replaceImages( + @PathVariable OwnerType ownerType, + @PathVariable Long ownerId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "전체 교체할 이미지 리스트를 전달합니다.", + required = true, + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = ReplaceImageRequestDTO.class)), + examples = { + @ExampleObject( + name = "Replace Example", + value = """ + [ + { "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte1.png" }, + { "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte2.png" }, + { "url":"https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte3.png" } + ] + """ + ) + } + ) + ) + @Valid @RequestBody List replaceImageDtos + ) { + List requests = replaceImageDtos.stream() + .map(r -> new ImageRequestDTO(ownerType, ownerId, r.getUrl())) + .collect(Collectors.toList()); + + var replacedImages = imageService.replaceImages(ownerType, ownerId, requests); + var imageResponseDTOS = replacedImages.stream() + .map(img -> new ImageResponseDTO( + img.getId(), + img.getUrl(), + img.getSortOrder(), + img.getOwnerType(), + img.getOwnerId() + )) + .collect(Collectors.toList()); + + return ResponseEntity.ok(imageResponseDTOS); + } + + @Operation(summary = "이미지 목록 조회", description = "주어진 ownerType, ownerId 에 매핑된 이미지 리스트를 정렬순으로 조회합니다.") + @GetMapping + public ResponseEntity> getImages( + @Parameter( + name = "ownerType", + description = "이미지 소유자 타입", + example = "ROOM", + required = true, + in = ParameterIn.QUERY + ) @RequestParam OwnerType ownerType, + @Parameter( + name = "ownerId", + description = "소유자 ID", + example = "1", + required = true, + in = ParameterIn.QUERY + ) + @RequestParam Long ownerId) { + var imageList = imageService.getImagesByOwner(ownerType, ownerId); + var imageResponseDTOS = imageList.stream() + .map(img -> new ImageResponseDTO( + img.getId(), img.getUrl(), img.getSortOrder(), img.getOwnerType(), img.getOwnerId())) + .collect(Collectors.toList()); + return ResponseEntity.ok(imageResponseDTOS); + } +} diff --git a/src/main/java/com/sumte/image/controller/S3FileUploadController.java b/src/main/java/com/sumte/image/controller/S3FileUploadController.java index 72c1637..d229e40 100644 --- a/src/main/java/com/sumte/image/controller/S3FileUploadController.java +++ b/src/main/java/com/sumte/image/controller/S3FileUploadController.java @@ -1,26 +1,32 @@ package com.sumte.image.controller; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.sumte.image.dto.S3UploadInfoDTO; +import com.sumte.image.entity.OwnerType; import com.sumte.image.service.S3FileUploadService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @RestController @RequestMapping("/s3") @RequiredArgsConstructor -@Tag(name = "S3 PresignedUrl API", description = "S3 PresignedUrl 발급 API") +@Tag(name = "이미지 API", description = "이미지 메타데이터 저장·조회·교체 API 및 S3 PresignedUrl 발급 API") public class S3FileUploadController { private final S3FileUploadService s3FileUploadService; - //@AllUser + /* @Operation(summary = "PresignedUrl 발급", description = "이미지, 파일 업로드를 위한 PresignedUrl을 발급합니다.") @GetMapping("/presigned-url") public ResponseEntity generatePresignedUrl(@RequestParam String fileName, @@ -28,4 +34,38 @@ public ResponseEntity generatePresignedUrl(@RequestParam String fileName String presignedUrl = s3FileUploadService.generatePresignedUrl(fileName, contentType); return ResponseEntity.ok(presignedUrl); } + */ + + @Operation( + summary = "Presigned URLs 일괄 발급", + description = """ + - 여러 개의 파일명에 대해 Presigned URL을 일괄 생성하여 반환합니다. " + - 이미지가 등록되는 파트에 대한 정보 OwnerType(GUESTHOUSE, ROOM, REVIEW)과 OwnerId(해당 파트의 ID)를 함께 전달해야 합니다. + - presignedUrl로 파일 업로드를 진행할 수 있습니다. + - imageUrl로 이미지 저장 API의 url 키로 매핑시키면 됩니다. (iamgeUrl은 S3에 저장된 이미지의 URL입니다.) + """ + ) + @GetMapping("/presigned-urls") + public ResponseEntity> generatePresignedUrls( + @RequestParam(name = "fileNames", defaultValue = "sumte1, ouchlogo") List fileNames, + @Parameter( + name = "ownerType", + description = "이미지 소유자 타입", + example = "ROOM", + required = true, + in = ParameterIn.QUERY + ) @RequestParam(name = "ownerType") OwnerType ownerType, + @Parameter( + name = "ownerId", + description = "소유자 ID", + example = "1", + required = true, + in = ParameterIn.QUERY + ) @RequestParam(name = "ownerId") Long ownerId + ) { + List s3UploadInfoDTO = s3FileUploadService.generatePresignedUrl( + fileNames, ownerType, ownerId + ); + return ResponseEntity.ok(s3UploadInfoDTO); + } } diff --git a/src/main/java/com/sumte/image/dto/ImageRequestDTO.java b/src/main/java/com/sumte/image/dto/ImageRequestDTO.java new file mode 100644 index 0000000..c094a0d --- /dev/null +++ b/src/main/java/com/sumte/image/dto/ImageRequestDTO.java @@ -0,0 +1,34 @@ +package com.sumte.image.dto; + +import com.sumte.image.entity.OwnerType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ImageRequestDTO", description = "이미지 메타데이터 등록 요청 DTO") +public class ImageRequestDTO { + + @Schema(description = "이미지 소유자 타입", example = "GUESTHOUSE") + @NotNull + private OwnerType ownerType; + + @Schema(description = "소유자 ID (예: 게스트하우스 ID, 룸 ID, 리뷰 ID 등)", example = "1") + @NotNull + private Long ownerId; + + @Schema(description = "S3에 업로드된 이미지 URL", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png") + @NotBlank + private String url; + + // @Schema(description = "정렬 순서 (0부터 시작)", example = "1") + // private Integer sortOrder; +} diff --git a/src/main/java/com/sumte/image/dto/ImageResponseDTO.java b/src/main/java/com/sumte/image/dto/ImageResponseDTO.java new file mode 100644 index 0000000..605e059 --- /dev/null +++ b/src/main/java/com/sumte/image/dto/ImageResponseDTO.java @@ -0,0 +1,32 @@ +package com.sumte.image.dto; + +import com.sumte.image.entity.OwnerType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ImageResponseDTO", description = "이미지 메타데이터 조회/등록 응답 DTO") +public class ImageResponseDTO { + + @Schema(description = "이미지 고유 ID", example = "1") + private Long id; + + @Schema(description = "이미지 URL 또는 object key", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png") + private String url; + + @Schema(description = "정렬 순서 (1부터 시작)", example = "1") + private Integer sortOrder; + + @Schema(description = "이미지 소유자 타입", example = "GUESTHOUSE") + private OwnerType ownerType; + + @Schema(description = "소유자 ID (게스트하우스/룸/리뷰 ID)", example = "1") + private Long ownerId; +} \ No newline at end of file diff --git a/src/main/java/com/sumte/image/dto/ReplaceImageRequestDTO.java b/src/main/java/com/sumte/image/dto/ReplaceImageRequestDTO.java new file mode 100644 index 0000000..aefa3fb --- /dev/null +++ b/src/main/java/com/sumte/image/dto/ReplaceImageRequestDTO.java @@ -0,0 +1,21 @@ +package com.sumte.image.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "ReplaceImageRequestDTO", + description = "이미지 전체 교체 요청 DTO (URL만 포함)") +public class ReplaceImageRequestDTO { + + @Schema(description = "S3 URL", example = "https://sumte-file.s3.ap-northeast-2.amazonaws.com/sumte.png") + @NotBlank + private String url; +} diff --git a/src/main/java/com/sumte/image/dto/S3UploadInfoDTO.java b/src/main/java/com/sumte/image/dto/S3UploadInfoDTO.java new file mode 100644 index 0000000..1eed6f9 --- /dev/null +++ b/src/main/java/com/sumte/image/dto/S3UploadInfoDTO.java @@ -0,0 +1,12 @@ +package com.sumte.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class S3UploadInfoDTO { + private String originalName; // 클라이언트가 보낸 원본 파일명 + private String imageUrl; // S3에 저장될 이미지 URL + private String presignedUrl; // 해당 키로 PUT 요청할 URL +} diff --git a/src/main/java/com/sumte/image/repository/ImageRepository.java b/src/main/java/com/sumte/image/repository/ImageRepository.java new file mode 100644 index 0000000..cd788b2 --- /dev/null +++ b/src/main/java/com/sumte/image/repository/ImageRepository.java @@ -0,0 +1,26 @@ +package com.sumte.image.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; + +public interface ImageRepository extends JpaRepository { + + boolean existsByOwnerTypeAndOwnerId(OwnerType ownerType, Long ownerId); + + @Query("SELECT MAX(i.sortOrder) FROM Image i WHERE i.ownerType = :ownerType AND i.ownerId = :ownerId") + Optional findMaxSortOrder( + @Param("ownerType") OwnerType ownerType, + @Param("ownerId") Long ownerId + ); + + List findByOwnerTypeAndOwnerIdOrderBySortOrderAsc(OwnerType ownerType, Long ownerId); + + List findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc(OwnerType ownerType, List ownerIds); +} diff --git a/src/main/java/com/sumte/image/service/ImageService.java b/src/main/java/com/sumte/image/service/ImageService.java new file mode 100644 index 0000000..6f31d72 --- /dev/null +++ b/src/main/java/com/sumte/image/service/ImageService.java @@ -0,0 +1,157 @@ +package com.sumte.image.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectsRequest; +import com.sumte.apiPayload.code.error.ImageErrorCode; +import com.sumte.apiPayload.exception.SumteException; +import com.sumte.guesthouse.repository.GuesthouseRepository; +import com.sumte.image.dto.ImageRequestDTO; +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.repository.ImageRepository; +import com.sumte.review.repository.ReviewRepository; +import com.sumte.room.repository.RoomRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ImageService { + private final ImageRepository imageRepository; + private final GuesthouseRepository guesthouseRepository; + private final RoomRepository roomRepository; + private final ReviewRepository reviewRepository; + private final AmazonS3 amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + @Value("${cloud.aws.s3.url-prefix}") + private String s3UrlPrefix; + + @Transactional + public List saveAllImages(List dtos) { + // 1) 같은 ownerType+ownerId 그룹핑 + Map, List> groups = dtos.stream() + .collect(Collectors.groupingBy( + dto -> Pair.of(dto.getOwnerType(), dto.getOwnerId()) + )); + + List toSave = new ArrayList<>(); + for (var entry : groups.entrySet()) { + OwnerType ownerType = entry.getKey().getFirst(); + Long ownerId = entry.getKey().getSecond(); + + List list = entry.getValue(); + + // 2) 동일 owner에 이미지 이미 등록되어 있는지 검사 + boolean existsImages = imageRepository.existsByOwnerTypeAndOwnerId(ownerType, ownerId); + if (existsImages) { + throw new SumteException(ImageErrorCode.IMAGE_ALREADY_EXISTS); + } + + // 3) 실제 엔티티 존재 여부 검증 + validateOwnerExists(ownerType, ownerId); + + // 4) 그룹별로 DB에서 현재 최대 sortOrder 조회 → +1 부터 + int nextOrder = imageRepository + .findMaxSortOrder(ownerType, ownerId) + .orElse(0) + 1; + + // 5) 그룹 내 DTO 순서대로 sortOrder 할당 + for (ImageRequestDTO dto : list) { + toSave.add(Image.builder() + .ownerType(ownerType) + .ownerId(ownerId) + .url(dto.getUrl()) + .sortOrder(nextOrder++) + .build() + ); + } + } + return imageRepository.saveAll(toSave); + } + + /** + * 전체 교체: 기존 이미지 DB 레코드와 S3 오브젝트를 전부 삭제하고, + * 전달된 URL 순서대로 새 이미지를 1부터 순차 저장합니다. + */ + @Transactional + public List replaceImages(OwnerType ownerType, Long ownerId, List imageRequestDTOS) { + // 1) 소유자 존재 검증 + validateOwnerExists(ownerType, ownerId); + + // 2) 기존 DB 레코드 조회 + List existing = imageRepository + .findByOwnerTypeAndOwnerIdOrderBySortOrderAsc(ownerType, ownerId); + + if (!existing.isEmpty()) { + // 3) S3 객체 키 수집 & 삭제 + List keys = existing.stream() + .map(Image::getUrl) + // 3-1) full URL 에서 prefix 제거 → object key만 남김 + .filter(url -> url.startsWith(s3UrlPrefix + "/")) + .map(url -> url.substring((s3UrlPrefix + "/").length())) + .map(DeleteObjectsRequest.KeyVersion::new) + .collect(Collectors.toList()); + + if (!keys.isEmpty()) { + DeleteObjectsRequest delReq = new DeleteObjectsRequest(bucketName) + .withKeys(keys); + amazonS3Client.deleteObjects(delReq); + } + // 4) DB 레코드 삭제 + imageRepository.deleteAllInBatch(existing); + } + + // 5) 새 DTO 리스트 순서대로 1부터 부여 + List toSave = new ArrayList<>(imageRequestDTOS.size()); + for (int i = 0; i < imageRequestDTOS.size(); i++) { + ImageRequestDTO dto = imageRequestDTOS.get(i); + toSave.add(Image.builder() + .ownerType(ownerType) + .ownerId(ownerId) + .url(dto.getUrl()) + .sortOrder(i + 1) + .build() + ); + } + + // 6) 저장 후 반환 + return imageRepository.saveAll(toSave); + } + + private void validateOwnerExists(OwnerType ownerType, Long ownerId) { + switch (ownerType) { + case GUESTHOUSE -> { + if (!guesthouseRepository.existsById(ownerId)) { + throw new SumteException(ImageErrorCode.GUESTHOUSE_NOT_FOUND); + } + } + case ROOM -> { + if (!roomRepository.existsById(ownerId)) { + throw new SumteException(ImageErrorCode.ROOM_NOT_FOUND); + } + } + case REVIEW -> { + if (!reviewRepository.existsById(ownerId)) { + throw new SumteException(ImageErrorCode.REVIEW_NOT_FOUND); + } + } + default -> throw new SumteException(ImageErrorCode.INVALID_OWNER_TYPE); + } + } + + public List getImagesByOwner(OwnerType ownerType, Long ownerId) { + return imageRepository.findByOwnerTypeAndOwnerIdOrderBySortOrderAsc(ownerType, ownerId); + } +} diff --git a/src/main/java/com/sumte/image/service/S3FileUploadService.java b/src/main/java/com/sumte/image/service/S3FileUploadService.java index a9c4825..2d93bd2 100644 --- a/src/main/java/com/sumte/image/service/S3FileUploadService.java +++ b/src/main/java/com/sumte/image/service/S3FileUploadService.java @@ -1,7 +1,11 @@ package com.sumte.image.service; -import java.net.URL; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -9,6 +13,8 @@ import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.sumte.image.dto.S3UploadInfoDTO; +import com.sumte.image.entity.OwnerType; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; @@ -22,22 +28,49 @@ public class S3FileUploadService { @Value("${cloud.aws.s3.bucket}") private String bucket; + @Value("${cloud.aws.s3.url-prefix}") + private String s3Endpoint; + @Transactional - public String generatePresignedUrl(String fileName, String contentType) { + public List generatePresignedUrl(List originalFileNames, + OwnerType ownerType, + Long ownerId /*, String contentType*/) { + // 만료 시간 설정 - Date expiration = new Date(); - long expTimeMillis = expiration.getTime() + (1000 * 60 * 5); // 5분 - expiration.setTime(expTimeMillis); - - // Presigned URL 요청 생성 - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName) - .withMethod(HttpMethod.PUT) - .withExpiration(expiration); - //.withContentType(contentType); - - // Presigned URL 생성 - URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); - return url.toString(); + Date expiration = new Date(System.currentTimeMillis() + 5 * 60 * 1000); + + // yyyy/MM/dd 형태의 날짜 경로 + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + return originalFileNames.stream() + .map(original -> { + // 1) 확장자 분리 + String ext = ""; + int idx = original.lastIndexOf('.'); + if (idx >= 0) { + ext = original.substring(idx); + } + + // 2) UUID 조합 및 전체 키 생성 + String uuid = UUID.randomUUID().toString(); + + String key = "images/" + ownerType + "/" + + ownerType + "_" + ownerId + "_" + datePath + "_" + uuid + ext; + + // Presigned URL 요청 생성 + GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, key) + .withMethod(HttpMethod.PUT) + .withExpiration(expiration); + + // 이미지 객체 URL 생성 + String fullUrl = s3Endpoint + "/" + key; + + // Presigned URL 생성 + String presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); + + return new S3UploadInfoDTO(original, fullUrl, presignedUrl); + + }) + .collect(Collectors.toList()); } } diff --git a/src/main/java/com/sumte/reservation/controller/ReservationController.java b/src/main/java/com/sumte/reservation/controller/ReservationController.java index 77d4f84..1ae8640 100644 --- a/src/main/java/com/sumte/reservation/controller/ReservationController.java +++ b/src/main/java/com/sumte/reservation/controller/ReservationController.java @@ -1,32 +1,39 @@ package com.sumte.reservation.controller; -import com.sumte.apiPayload.ApiResponse; -import com.sumte.apiPayload.exception.annotation.CheckPage; -import com.sumte.apiPayload.exception.annotation.CheckPageSize; -import com.sumte.security.authorization.UserId; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +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.RestController; +import com.sumte.apiPayload.ApiResponse; +import com.sumte.apiPayload.exception.annotation.CheckPage; +import com.sumte.apiPayload.exception.annotation.CheckPageSize; import com.sumte.reservation.dto.ReservationRequestDTO; import com.sumte.reservation.dto.ReservationResponseDTO; import com.sumte.reservation.service.ReservationService; +import com.sumte.security.authorization.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/reservations") @Validated -@Tag(name = "Reservation", description = "예약 관련 API (생성, 조회, 수정, 삭제 등)") +@Tag(name = "예약 API", description = "예약 관련 API (생성, 조회, 수정, 삭제 등)") public class ReservationController { private final ReservationService reservationService; @@ -34,8 +41,8 @@ public class ReservationController { @PostMapping @Operation(summary = "예약 생성 API", description = "요청 본문으로 객실 ID, 투숙 인원, 날짜 정보를 입력받고, 헤더의 userId를 통해 예약을 생성합니다.") public ResponseEntity> createReservation( - @Parameter(description = "요청한 사용자 ID") @UserId Long userId, - @Valid @RequestBody ReservationRequestDTO.CreateReservationDTO request + @Parameter(description = "요청한 사용자 ID") @UserId Long userId, + @Valid @RequestBody ReservationRequestDTO.CreateReservationDTO request ) { reservationService.createReservation(request, userId); return ResponseEntity.ok(ApiResponse.success(null)); @@ -44,9 +51,9 @@ public ResponseEntity> createReservation( @GetMapping("/my") @Operation(summary = "내 예약 목록 조회 API", description = "내가 예약한 숙소 목록을 페이지 단위로 조회합니다.") public ResponseEntity>> getMyReservations( - @Parameter(description = "요청한 사용자 ID") @UserId Long userId, - @CheckPage @RequestParam(name = "page", defaultValue = "1") int page, - @CheckPageSize @RequestParam(name = "size", defaultValue = "10") int size + @Parameter(description = "요청한 사용자 ID") @UserId Long userId, + @CheckPage @RequestParam(name = "page", defaultValue = "1") int page, + @CheckPageSize @RequestParam(name = "size", defaultValue = "10") int size ) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "startDate")); Page result = reservationService.getMyReservations(userId, pageable); @@ -56,18 +63,19 @@ public ResponseEntity> @GetMapping("/{id}") @Operation(summary = "예약 상세 조회 API", description = "예약 ID를 기준으로 상세 정보를 조회합니다.") public ResponseEntity> getReservationDetail( - @Parameter(description = "요청한 사용자 ID") @UserId Long userId, - @PathVariable("id") Long reservationId + @Parameter(description = "요청한 사용자 ID") @UserId Long userId, + @PathVariable("id") Long reservationId ) { - ReservationResponseDTO.ReservationDetailDTO result = reservationService.getReservationDetail(reservationId, userId); + ReservationResponseDTO.ReservationDetailDTO result = reservationService.getReservationDetail(reservationId, + userId); return ResponseEntity.ok(ApiResponse.success(result)); } @PatchMapping("/{id}") @Operation(summary = "예약 취소 API", description = "예약 ID를 기준으로 사용자의 예약을 취소합니다.") public ResponseEntity> cancelReservation( - @Parameter(description = "요청한 사용자 ID") @UserId Long userId, - @PathVariable("id") Long reservationId + @Parameter(description = "요청한 사용자 ID") @UserId Long userId, + @PathVariable("id") Long reservationId ) { reservationService.cancelReservation(reservationId, userId); return ResponseEntity.ok(ApiResponse.success(null)); diff --git a/src/main/java/com/sumte/reservation/converter/ReservationConverter.java b/src/main/java/com/sumte/reservation/converter/ReservationConverter.java index 6bc0a14..ed73b0a 100644 --- a/src/main/java/com/sumte/reservation/converter/ReservationConverter.java +++ b/src/main/java/com/sumte/reservation/converter/ReservationConverter.java @@ -1,8 +1,10 @@ package com.sumte.reservation.converter; -import com.sumte.guesthouse.entity.Guesthouse; +import java.time.temporal.ChronoUnit; + import org.springframework.stereotype.Component; +import com.sumte.guesthouse.entity.Guesthouse; import com.sumte.reservation.dto.ReservationRequestDTO; import com.sumte.reservation.dto.ReservationResponseDTO; import com.sumte.reservation.entity.Reservation; @@ -10,8 +12,6 @@ import com.sumte.room.entity.Room; import com.sumte.user.entity.User; -import java.time.temporal.ChronoUnit; - @Component public class ReservationConverter { @@ -33,45 +33,48 @@ public ReservationResponseDTO.CreateReservationDTO toCreateResponse(Reservation .build(); } - public ReservationResponseDTO.MyReservationDTO toMyReservationDTO(Reservation reservation, boolean canWriteReview, boolean reviewWritten) { + public ReservationResponseDTO.MyReservationDTO toMyReservationDTO(Reservation reservation, String firstRoomImageUrl, + boolean canWriteReview, + boolean reviewWritten) { Room room = reservation.getRoom(); Guesthouse guestHouse = room.getGuesthouse(); - int nightCount = (int) ChronoUnit.DAYS.between(reservation.getStartDate(), reservation.getEndDate()); + int nightCount = (int)ChronoUnit.DAYS.between(reservation.getStartDate(), reservation.getEndDate()); return ReservationResponseDTO.MyReservationDTO.builder() - .id(reservation.getId()) - .guestHouseName(guestHouse.getName()) - .roomName(room.getName()) - .imageUrl(room.getImageUrl()) - .startDate(reservation.getStartDate()) - .endDate(reservation.getEndDate()) - .adultCount(reservation.getAdultCount()) - .childCount(reservation.getChildCount()) - .nightCount(nightCount) - .status(reservation.getReservationStatus()) - .canWriteReview(canWriteReview) - .reviewWritten(reviewWritten) - .build(); + .id(reservation.getId()) + .guestHouseName(guestHouse.getName()) + .roomName(room.getName()) + .imageUrl(firstRoomImageUrl) + .startDate(reservation.getStartDate()) + .endDate(reservation.getEndDate()) + .adultCount(reservation.getAdultCount()) + .childCount(reservation.getChildCount()) + .nightCount(nightCount) + .status(reservation.getReservationStatus()) + .canWriteReview(canWriteReview) + .reviewWritten(reviewWritten) + .build(); } - public ReservationResponseDTO.ReservationDetailDTO toReservationDetailDTO(Reservation reservation) { + public ReservationResponseDTO.ReservationDetailDTO toReservationDetailDTO(Reservation reservation, + String firstRoomImageUrl) { Room room = reservation.getRoom(); Guesthouse guestHouse = room.getGuesthouse(); - int nightCount = (int) ChronoUnit.DAYS.between(reservation.getStartDate(), reservation.getEndDate()); + int nightCount = (int)ChronoUnit.DAYS.between(reservation.getStartDate(), reservation.getEndDate()); return ReservationResponseDTO.ReservationDetailDTO.builder() - .reservationId(reservation.getId()) - .guestHouseName(guestHouse.getName()) - .roomName(room.getName()) - .imageUrl(room.getImageUrl()) - .adultCount(reservation.getAdultCount()) - .childCount(reservation.getChildCount()) - .startDate(reservation.getStartDate()) - .endDate(reservation.getEndDate()) - .status(reservation.getReservationStatus().name()) - .totalPrice(room.getPrice()) - .nightCount(nightCount) - .reservedAt(reservation.getCreatedAt()) - .build(); + .reservationId(reservation.getId()) + .guestHouseName(guestHouse.getName()) + .roomName(room.getName()) + .imageUrl(firstRoomImageUrl) + .adultCount(reservation.getAdultCount()) + .childCount(reservation.getChildCount()) + .startDate(reservation.getStartDate()) + .endDate(reservation.getEndDate()) + .status(reservation.getReservationStatus().name()) + .totalPrice(room.getPrice()) + .nightCount(nightCount) + .reservedAt(reservation.getCreatedAt()) + .build(); } } diff --git a/src/main/java/com/sumte/reservation/service/ReservationServiceImpl.java b/src/main/java/com/sumte/reservation/service/ReservationServiceImpl.java index bcc1d0e..e301ae0 100644 --- a/src/main/java/com/sumte/reservation/service/ReservationServiceImpl.java +++ b/src/main/java/com/sumte/reservation/service/ReservationServiceImpl.java @@ -1,34 +1,37 @@ package com.sumte.reservation.service; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.sumte.apiPayload.code.error.CommonErrorCode; import com.sumte.apiPayload.code.error.ReservationErrorCode; import com.sumte.apiPayload.exception.SumteException; +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.repository.ImageRepository; import com.sumte.payment.entity.Payment; import com.sumte.payment.entity.PaymentStatus; import com.sumte.payment.repository.PaymentRepository; -import com.sumte.reservation.entity.ReservationStatus; -import com.sumte.review.repository.ReviewRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - import com.sumte.reservation.converter.ReservationConverter; import com.sumte.reservation.dto.ReservationRequestDTO; import com.sumte.reservation.dto.ReservationResponseDTO; import com.sumte.reservation.entity.Reservation; +import com.sumte.reservation.entity.ReservationStatus; import com.sumte.reservation.repository.ReservationRepository; +import com.sumte.review.repository.ReviewRepository; import com.sumte.room.entity.Room; import com.sumte.room.repository.RoomRepository; import com.sumte.user.entity.User; import com.sumte.user.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; @Service @RequiredArgsConstructor @@ -40,17 +43,20 @@ public class ReservationServiceImpl implements ReservationService { private final UserRepository userRepository; private final PaymentRepository paymentRepository; private final ReviewRepository reviewRepository; + private final ImageRepository imageRepository; @Override @Transactional - public ReservationResponseDTO.CreateReservationDTO createReservation(ReservationRequestDTO.CreateReservationDTO request, Long userId) { + public ReservationResponseDTO.CreateReservationDTO createReservation( + ReservationRequestDTO.CreateReservationDTO request, Long userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new SumteException(CommonErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new SumteException(CommonErrorCode.USER_NOT_FOUND)); Room room = roomRepository.findById(request.getRoomId()) - .orElseThrow(() -> new SumteException(ReservationErrorCode.ROOM_NOT_FOUND)); + .orElseThrow(() -> new SumteException(ReservationErrorCode.ROOM_NOT_FOUND)); // 닐짜 유효성 검사 - if (request.getStartDate().isAfter(request.getEndDate()) || request.getStartDate().isEqual(request.getEndDate())) { + if (request.getStartDate().isAfter(request.getEndDate()) || request.getStartDate() + .isEqual(request.getEndDate())) { throw new SumteException(ReservationErrorCode.RESERVATION_DATE_INVALID); } // 정원 초과 검사 @@ -59,12 +65,13 @@ public ReservationResponseDTO.CreateReservationDTO createReservation(Reservation throw new SumteException(ReservationErrorCode.ROOM_CAPACITY_EXCEEDED); } // 중복 예약 검사 - boolean isOverlapping = reservationRepository.existsOverlappingReservation(room, request.getStartDate(), request.getEndDate()); - if(isOverlapping) { + boolean isOverlapping = reservationRepository.existsOverlappingReservation(room, request.getStartDate(), + request.getEndDate()); + if (isOverlapping) { throw new SumteException(ReservationErrorCode.ALREADY_RESERVED); } - Reservation reservation = reservationConverter.toEntity(request,user,room); + Reservation reservation = reservationConverter.toEntity(request, user, room); reservationRepository.save(reservation); return reservationConverter.toCreateResponse(reservation); } @@ -73,16 +80,28 @@ public ReservationResponseDTO.CreateReservationDTO createReservation(Reservation @Transactional(readOnly = true) public Page getMyReservations(Long userId, Pageable pageable) { User user = userRepository.findById(userId) - .orElseThrow(() -> new SumteException(CommonErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new SumteException(CommonErrorCode.USER_NOT_FOUND)); Page reservations = reservationRepository.findAllByUser(user, pageable); return reservations.map(reservation -> { boolean isComplete = reservation.getReservationStatus().equals(ReservationStatus.COMPLETED); - boolean reviewWritten = reviewRepository.existsByUserIdAndRoomGuesthouseId(user.getId(), reservation.getRoom().getGuesthouse().getId()); + boolean reviewWritten = reviewRepository.existsByUserIdAndRoomGuesthouseId(user.getId(), + reservation.getRoom().getGuesthouse().getId()); boolean canWriteReview = isComplete && !reviewWritten; - return reservationConverter.toMyReservationDTO(reservation,canWriteReview,reviewWritten); + // 첫 번째 방 이미지 URL 조회 + String firstImageUrl = imageRepository + .findByOwnerTypeAndOwnerIdOrderBySortOrderAsc( + OwnerType.ROOM, + reservation.getRoom().getId() + ) + .stream() + .map(Image::getUrl) + .findFirst() + .orElse(null); + + return reservationConverter.toMyReservationDTO(reservation, firstImageUrl, canWriteReview, reviewWritten); }); } @@ -90,20 +109,31 @@ public Page getMyReservations(Long user @Transactional(readOnly = true) public ReservationResponseDTO.ReservationDetailDTO getReservationDetail(Long reservationId, Long userId) { Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(() -> new SumteException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + .orElseThrow(() -> new SumteException(ReservationErrorCode.RESERVATION_NOT_FOUND)); if (!reservation.getUser().getId().equals(userId)) { throw new SumteException(CommonErrorCode.FORBIDDEN); } - return reservationConverter.toReservationDetailDTO(reservation); + // 첫 번째 방 이미지 URL 조회 + String firstImageUrl = imageRepository + .findByOwnerTypeAndOwnerIdOrderBySortOrderAsc( + OwnerType.ROOM, + reservation.getRoom().getId() + ) + .stream() + .map(Image::getUrl) + .findFirst() + .orElse(null); + + return reservationConverter.toReservationDetailDTO(reservation, firstImageUrl); } @Override @Transactional public void cancelReservation(Long reservationId, Long userId) { Reservation reservation = reservationRepository.findById(reservationId) - .orElseThrow(() -> new SumteException(ReservationErrorCode.RESERVATION_NOT_FOUND)); + .orElseThrow(() -> new SumteException(ReservationErrorCode.RESERVATION_NOT_FOUND)); // 사용자 본인 확인 if (!reservation.getUser().getId().equals(userId)) { @@ -129,14 +159,15 @@ public void updateCompletedReservations() { boolean isAfterCheckout = endDate.isBefore(today) || (endDate.isEqual(today) && checkoutTime.isBefore(now)); - if (!isAfterCheckout) continue; + if (!isAfterCheckout) + continue; Optional paymentOpt = paymentRepository.findByReservation(reservation); boolean isPaid = paymentOpt - .map(Payment::getPaymentStatus) - .filter(status -> status == PaymentStatus.PAID) - .isPresent(); + .map(Payment::getPaymentStatus) + .filter(status -> status == PaymentStatus.PAID) + .isPresent(); if (isPaid) { reservation.complete(); diff --git a/src/main/java/com/sumte/review/controller/ReviewController.java b/src/main/java/com/sumte/review/controller/ReviewController.java index 4f7e30f..75f16c9 100644 --- a/src/main/java/com/sumte/review/controller/ReviewController.java +++ b/src/main/java/com/sumte/review/controller/ReviewController.java @@ -27,7 +27,7 @@ @Tag(name = "리뷰", description = "리뷰 관련 API") @RestController -@RequestMapping("/api/reviews") +@RequestMapping("reviews") @RequiredArgsConstructor public class ReviewController { @@ -35,29 +35,29 @@ public class ReviewController { @Operation(summary = "리뷰 등록") @PostMapping - public ResponseEntity createReview( + public ResponseEntity createReview( @UserId Long userId, @RequestBody @Valid ReviewRequestDto dto) { - reviewService.createReview(userId, dto.getRoomId(), dto); - return ResponseEntity.noContent().build(); + Long reviewId = reviewService.createReview(userId, dto); + return ResponseEntity.ok(reviewId); } @Operation(summary = "리뷰 수정") - @PatchMapping("/{id}") - public ResponseEntity updateReview( + @PatchMapping("/{reviewId}") + public ResponseEntity updateReview( @UserId Long userId, - @PathVariable Long id, + @PathVariable Long reviewId, @RequestBody @Valid ReviewRequestDto dto) { - reviewService.updateReview(userId, id, dto); - return ResponseEntity.noContent().build(); + reviewService.updateReview(userId, reviewId, dto); + return ResponseEntity.ok(reviewId); } @Operation(summary = "리뷰 삭제") - @DeleteMapping("/{id}") + @DeleteMapping("/{reviewId}") public ResponseEntity deleteReview( @UserId Long userId, - @PathVariable Long id) { - reviewService.deleteReview(userId, id); + @PathVariable Long reviewId) { + reviewService.deleteReview(userId, reviewId); return ResponseEntity.noContent().build(); } @@ -74,7 +74,7 @@ public ResponseEntity> getReviewsByGuesthouse( } @Operation(summary = "내가 남긴 리뷰 전체 조회") - @GetMapping("/myreviews") + @GetMapping("/myReviews") public ResponseEntity> getMyReviews( @UserId Long userId, @ParameterObject diff --git a/src/main/java/com/sumte/review/converter/ReviewConverter.java b/src/main/java/com/sumte/review/converter/ReviewConverter.java index 26e3355..32654ef 100644 --- a/src/main/java/com/sumte/review/converter/ReviewConverter.java +++ b/src/main/java/com/sumte/review/converter/ReviewConverter.java @@ -12,14 +12,12 @@ public static Review toEntity(ReviewRequestDto dto, User user, Room room) { return Review.builder() .user(user) .room(room) - .imageUrl(dto.getImageUrl()) .contents(dto.getContents()) .score(dto.getScore()) .build(); } public static void updateEntity(Review review, ReviewRequestDto dto) { - review.changeImageUrl(dto.getImageUrl()); review.changeContents(dto.getContents()); review.changeScore(dto.getScore()); } @@ -29,7 +27,6 @@ public static ReviewResponseDto toDto(Review review) { review.getId(), review.getUser().getId(), review.getRoom().getId(), - review.getImageUrl(), review.getContents(), review.getScore() ); diff --git a/src/main/java/com/sumte/review/dto/ReviewRequestDto.java b/src/main/java/com/sumte/review/dto/ReviewRequestDto.java index eb730e4..d85c5b7 100644 --- a/src/main/java/com/sumte/review/dto/ReviewRequestDto.java +++ b/src/main/java/com/sumte/review/dto/ReviewRequestDto.java @@ -18,8 +18,6 @@ public class ReviewRequestDto { @NotNull private Long roomId; - private String imageUrl; - @NotBlank private String contents; diff --git a/src/main/java/com/sumte/review/dto/ReviewResponseDto.java b/src/main/java/com/sumte/review/dto/ReviewResponseDto.java index 4602393..c52f099 100644 --- a/src/main/java/com/sumte/review/dto/ReviewResponseDto.java +++ b/src/main/java/com/sumte/review/dto/ReviewResponseDto.java @@ -13,7 +13,6 @@ public class ReviewResponseDto { private Long id; private Long userId; private Long roomId; - private String imageUrl; private String contents; private int score; diff --git a/src/main/java/com/sumte/review/dto/ReviewSearchDto.java b/src/main/java/com/sumte/review/dto/ReviewSearchDto.java index a70c295..1416192 100644 --- a/src/main/java/com/sumte/review/dto/ReviewSearchDto.java +++ b/src/main/java/com/sumte/review/dto/ReviewSearchDto.java @@ -1,6 +1,7 @@ package com.sumte.review.dto; import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,7 +16,7 @@ @Getter public class ReviewSearchDto { private Long id; - private String imageUrl; + private List imageUrls; private String contents; private int score; private String authorNickname; diff --git a/src/main/java/com/sumte/review/entity/Review.java b/src/main/java/com/sumte/review/entity/Review.java index dc8650b..3ab63b0 100644 --- a/src/main/java/com/sumte/review/entity/Review.java +++ b/src/main/java/com/sumte/review/entity/Review.java @@ -35,22 +35,9 @@ public class Review extends BaseTimeEntity { @JoinColumn(name = "room_id") private Room room; - private String imageUrl; private String contents; private int score; - //도메인 메서드 추가 - // 리뷰 작성 시 연관 관계 설정 -> 리뷰생성할때 방과 사용자가 묶여서 생성 - public void assignUserAndRoom(User user, Room room) { - this.user = user; - this.room = room; - } - - // 이미지 URL 변경 - public void changeImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - // 내용 변경 public void changeContents(String contents) { this.contents = contents; diff --git a/src/main/java/com/sumte/review/service/ReviewService.java b/src/main/java/com/sumte/review/service/ReviewService.java index 8358b70..6d035d1 100644 --- a/src/main/java/com/sumte/review/service/ReviewService.java +++ b/src/main/java/com/sumte/review/service/ReviewService.java @@ -1,17 +1,24 @@ package com.sumte.review.service; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.sumte.apiPayload.code.error.ReviewErrorCode; import com.sumte.apiPayload.exception.SumteException; +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.repository.ImageRepository; import com.sumte.review.converter.ReviewConverter; import com.sumte.review.dto.ReviewRequestDto; -import com.sumte.review.dto.ReviewResponseDto; import com.sumte.review.dto.ReviewSearchDto; import com.sumte.review.entity.Review; import com.sumte.review.repository.ReviewRepository; @@ -29,9 +36,10 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final RoomRepository roomRepository; + private final ImageRepository imageRepository; @Transactional - public ReviewResponseDto createReview(Long userId, Long roomId, ReviewRequestDto dto) { + public Long createReview(Long userId, ReviewRequestDto dto) { User user = userRepository.findById(userId) .orElseThrow(() -> new SumteException(ReviewErrorCode.USER_NOT_FOUND)); Room room = roomRepository.findById(dto.getRoomId()) @@ -39,19 +47,18 @@ public ReviewResponseDto createReview(Long userId, Long roomId, ReviewRequestDto Review review = ReviewConverter.toEntity(dto, user, room); Review saved = reviewRepository.save(review); - return ReviewConverter.toDto(saved); + return saved.getId(); } @Transactional - public ReviewResponseDto updateReview(Long userId, Long id, ReviewRequestDto dto) { - Optional opt = reviewRepository.findByIdAndUserId(id, userId); + public Void updateReview(Long userId, Long reviewId, ReviewRequestDto dto) { + Optional opt = reviewRepository.findByIdAndUserId(reviewId, userId); if (opt.isPresent()) { Review review = opt.get(); ReviewConverter.updateEntity(review, dto); - return ReviewConverter.toDto(review); } - // id 자체가 존재하지 않으면 NOT_FOUND로 - if (!reviewRepository.existsById(id)) { + // reviewId 자체가 존재하지 않으면 NOT_FOUND로 + if (!reviewRepository.existsById(reviewId)) { throw new SumteException(ReviewErrorCode.REVIEW_NOT_FOUND); } // id는 있지만 userId가 다르면 UNAUTH로 @@ -71,30 +78,102 @@ public void deleteReview(Long userId, Long id) { throw new SumteException(ReviewErrorCode.UNAUTHORIZED); } - private ReviewSearchDto toListDto(Review r) { - return new ReviewSearchDto( - r.getId(), - r.getImageUrl(), - r.getContents(), - r.getScore(), - r.getUser().getNickname(), - r.getCreatedAt() - ); - } - @Transactional(readOnly = true) public Page getReviewsByGuesthouse(Long guesthouseId, Pageable pageable) { - return reviewRepository - .findAllByRoomGuesthouseId(guesthouseId, pageable) - .map(this::toListDto); + // 1) 페이징된 리뷰 조회 + Page reviewPage = reviewRepository + .findAllByRoomGuesthouseId(guesthouseId, pageable); + + List reviews = reviewPage.getContent(); + if (reviews.isEmpty()) { + return Page.empty(pageable); + } + + // 2) 리뷰 ID 리스트 뽑아서 이미지 일괄 조회 + List reviewIds = reviews.stream() + .map(Review::getId) + .toList(); + + List images = imageRepository + .findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc( + OwnerType.REVIEW, reviewIds + ); + + // 3) ownerId 별로 URL 그룹핑 + Map> urlsByReview = images.stream() + .collect(Collectors.groupingBy( + Image::getOwnerId, + Collectors.mapping(Image::getUrl, Collectors.toList()) + )); + + // 4) DTO 변환 (이미 없는 toListDto 대신 직접 매핑) + List dtos = reviews.stream() + .map(r -> new ReviewSearchDto( + r.getId(), + urlsByReview.getOrDefault(r.getId(), Collections.emptyList()), + r.getContents(), + r.getScore(), + r.getUser().getNickname(), + r.getCreatedAt() + )) + .toList(); + + // 5) PageImpl 으로 포장해서 반환 + return new PageImpl<>( + dtos, + pageable, + reviewPage.getTotalElements() + ); } //리뷰테이블에서 userid와 일치하는 모든 리뷰 가져오기, 이 각 엔티티->dto로 변환 @Transactional(readOnly = true) public Page getMyReviews(Long userId, Pageable pageable) { - return reviewRepository - .findAllByUserId(userId, pageable) - .map(this::toListDto); - } + // 1) 내가 쓴 리뷰 페이징 조회 + Page reviewPage = reviewRepository + .findAllByUserId(userId, pageable); + + List reviews = reviewPage.getContent(); + if (reviews.isEmpty()) { + return Page.empty(pageable); + } + + // 2) 리뷰 ID 리스트 + List reviewIds = reviews.stream() + .map(Review::getId) + .toList(); + // 3) 해당 리뷰들의 이미지 일괄 조회 + List images = imageRepository + .findByOwnerTypeAndOwnerIdInOrderByOwnerIdAscSortOrderAsc( + OwnerType.REVIEW, + reviewIds + ); + + // 4) ownerId 별 URL 그룹핑 + Map> urlsByReview = images.stream() + .collect(Collectors.groupingBy( + Image::getOwnerId, + Collectors.mapping(Image::getUrl, Collectors.toList()) + )); + + // 5) DTO 변환 + List dtos = reviews.stream() + .map(r -> new ReviewSearchDto( + r.getId(), + urlsByReview.getOrDefault(r.getId(), Collections.emptyList()), + r.getContents(), + r.getScore(), + r.getUser().getNickname(), + r.getCreatedAt() + )) + .toList(); + + // 6) PageImpl 포장 + return new PageImpl<>( + dtos, + pageable, + reviewPage.getTotalElements() + ); + } } diff --git a/src/main/java/com/sumte/room/controller/RoomController.java b/src/main/java/com/sumte/room/controller/RoomController.java index db8be29..96763fc 100644 --- a/src/main/java/com/sumte/room/controller/RoomController.java +++ b/src/main/java/com/sumte/room/controller/RoomController.java @@ -30,7 +30,7 @@ @RestController @RequiredArgsConstructor -@Tag(name = "Room API", description = "room을 추가/수정/삭제 하는 api입니다") +@Tag(name = "객실 API", description = "특정 게스트하우스의 객실을 추가/수정/삭제 하는 api입니다") @RequestMapping("/guesthouse") public class RoomController { private final RoomCommandService roomCommandService; @@ -41,13 +41,12 @@ public class RoomController { @Parameters({ @Parameter(name = "guesthouseId", description = "숙소 아이디를 넘겨주세요") }) - public ApiResponse registerRoom( + public ApiResponse registerRoom( @PathVariable Long guesthouseId, @RequestBody @Valid RoomRequestDTO.RegisterRoom dto) { - roomCommandService.registerRoom(dto, guesthouseId); - - return ApiResponse.successWithNoData(); - + RoomResponseDTO.Registered room = roomCommandService.registerRoom(dto, guesthouseId); + Long roomId = room.getRoomId(); + return ApiResponse.created(roomId); } @DeleteMapping("/{guesthouseId}/room/{roomId}") @@ -71,12 +70,12 @@ public ApiResponse deleteRoom( @Parameter(name = "guesthouseId", description = "숙소 아이디를 넘겨주세요"), @Parameter(name = "roomId", description = "방 아이디를 넘겨주세요.") }) - public ApiResponse updateRoom( + public ApiResponse updateRoom( @PathVariable Long guesthouseId, @PathVariable Long roomId, @RequestBody @Valid RoomRequestDTO.UpdateRoom dto ) { roomCommandService.updateRoom(dto, guesthouseId, roomId); - return ApiResponse.successWithNoData(); + return ApiResponse.success(roomId); } @GetMapping("/room/{roomId}") diff --git a/src/main/java/com/sumte/room/converter/RoomConverter.java b/src/main/java/com/sumte/room/converter/RoomConverter.java index e8df474..9b66a3d 100644 --- a/src/main/java/com/sumte/room/converter/RoomConverter.java +++ b/src/main/java/com/sumte/room/converter/RoomConverter.java @@ -42,7 +42,6 @@ public RoomResponseDTO.RoomSummary toRoomSummary(Room room, boolean isReservable .id(room.getId()) .name(room.getName()) .price(room.getPrice()) - .imageUrl(room.getImageUrl()) .standardCount(room.getStandardCount()) .totalCount(room.getTotalCount()) .checkin(room.getCheckin()) diff --git a/src/main/java/com/sumte/room/dto/RoomRequestDTO.java b/src/main/java/com/sumte/room/dto/RoomRequestDTO.java index ee6e063..1af216c 100644 --- a/src/main/java/com/sumte/room/dto/RoomRequestDTO.java +++ b/src/main/java/com/sumte/room/dto/RoomRequestDTO.java @@ -32,9 +32,6 @@ public static class RegisterRoom { @NotNull(message = "최대 인원을 입력해주세요") Long totalCount; - - String imageUrl; - } @Getter @@ -48,7 +45,5 @@ public static class UpdateRoom { LocalTime checkout; Long standardCount; Long totalCount; - String imageUrl; } - } diff --git a/src/main/java/com/sumte/room/dto/RoomResponseDTO.java b/src/main/java/com/sumte/room/dto/RoomResponseDTO.java index 9009378..e227649 100644 --- a/src/main/java/com/sumte/room/dto/RoomResponseDTO.java +++ b/src/main/java/com/sumte/room/dto/RoomResponseDTO.java @@ -1,6 +1,7 @@ package com.sumte.room.dto; import java.time.LocalTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -38,6 +39,22 @@ public static class Deleted { @NoArgsConstructor @AllArgsConstructor public static class GetRoomResponse { + Long id; + String name; + Long price; + Long standardCount; + Long totalCount; + String content; + LocalTime checkin; + LocalTime checkout; + List imageUrls; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class GetPreviewRoomByGuesthouseResponse { Long id; String name; Long price; diff --git a/src/main/java/com/sumte/room/entity/Room.java b/src/main/java/com/sumte/room/entity/Room.java index c5767af..51907dd 100644 --- a/src/main/java/com/sumte/room/entity/Room.java +++ b/src/main/java/com/sumte/room/entity/Room.java @@ -35,7 +35,6 @@ public class Room extends BaseTimeEntity { private LocalTime checkout; private Long standardCount; private Long totalCount; - private String imageUrl; public static Room createRoomEntity(RoomRequestDTO.RegisterRoom dto) { Room room = new Room(); @@ -46,7 +45,6 @@ public static Room createRoomEntity(RoomRequestDTO.RegisterRoom dto) { room.checkout = dto.getCheckout(); room.standardCount = dto.getStandardCount(); room.totalCount = dto.getTotalCount(); - room.imageUrl = dto.getImageUrl(); return room; } @@ -81,9 +79,4 @@ public void setStandardCount(Long standardCount) { public void setTotalCount(Long totalCount) { this.totalCount = totalCount; } - - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - } diff --git a/src/main/java/com/sumte/room/service/RoomCommandServiceImpl.java b/src/main/java/com/sumte/room/service/RoomCommandServiceImpl.java index 51fb1a0..b57f060 100644 --- a/src/main/java/com/sumte/room/service/RoomCommandServiceImpl.java +++ b/src/main/java/com/sumte/room/service/RoomCommandServiceImpl.java @@ -87,9 +87,6 @@ public RoomResponseDTO.Updated updateRoom(RoomRequestDTO.UpdateRoom dto, Long gu if (dto.getTotalCount() != null) { room.setTotalCount(dto.getTotalCount()); } - if (dto.getImageUrl() != null) { - room.setImageUrl(dto.getImageUrl()); - } return roomConverter.toUpdateEntity(room); diff --git a/src/main/java/com/sumte/room/service/RoomQueryServiceImpl.java b/src/main/java/com/sumte/room/service/RoomQueryServiceImpl.java index cbc5f55..588ffcd 100644 --- a/src/main/java/com/sumte/room/service/RoomQueryServiceImpl.java +++ b/src/main/java/com/sumte/room/service/RoomQueryServiceImpl.java @@ -8,6 +8,9 @@ import com.sumte.apiPayload.code.error.CommonErrorCode; import com.sumte.apiPayload.exception.SumteException; +import com.sumte.image.entity.Image; +import com.sumte.image.entity.OwnerType; +import com.sumte.image.repository.ImageRepository; import com.sumte.reservation.repository.ReservationRepository; import com.sumte.room.converter.RoomConverter; import com.sumte.room.dto.RoomResponseDTO; @@ -24,14 +27,24 @@ public class RoomQueryServiceImpl implements RoomQueryService { private final RoomRepository roomRepository; private final RoomConverter roomConverter; private final ReservationRepository reservationRepository; + private final ImageRepository imageRepository; @Override @Transactional public RoomResponseDTO.GetRoomResponse getRoomById(Long roomId) { - Room room = roomRepository.findById(roomId).orElseThrow( - () -> new SumteException(CommonErrorCode.NOT_EXIST_ROOM) - ); + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new SumteException(CommonErrorCode.NOT_EXIST_ROOM)); + // 2) 이미지 테이블에서 이 방에 속한 모든 이미지 조회 + List imageUrls = imageRepository + .findByOwnerTypeAndOwnerIdOrderBySortOrderAsc( + OwnerType.ROOM, roomId + ) + .stream() + .map(Image::getUrl) + .toList(); + + // 3) DTO 빌드 return RoomResponseDTO.GetRoomResponse.builder() .id(room.getId()) .name(room.getName()) @@ -39,7 +52,7 @@ public RoomResponseDTO.GetRoomResponse getRoomById(Long roomId) { .checkout(room.getCheckout()) .content(room.getContents()) .price(room.getPrice()) - .imageUrl(room.getImageUrl()) + .imageUrls(imageUrls) // ★ 단일 imageUrl → imageUrls .standardCount(room.getStandardCount()) .totalCount(room.getTotalCount()) .build(); @@ -55,5 +68,4 @@ public List getRoomsByGuesthouse(Long guesthouseId, }) .collect(Collectors.toList()); } - } diff --git a/src/main/java/com/sumte/security/controller/SignInController.java b/src/main/java/com/sumte/security/controller/SignInController.java index 55abfd2..bc88f26 100644 --- a/src/main/java/com/sumte/security/controller/SignInController.java +++ b/src/main/java/com/sumte/security/controller/SignInController.java @@ -18,7 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -@Tag(name = "로그인 API", description = "로그인 API입니다.") +@Tag(name = "회원가입/로그인 API", description = "회원가입/로그인 관련 API입니다.") @RestController @RequestMapping("/users/login") @RequiredArgsConstructor diff --git a/src/main/java/com/sumte/security/controller/SignUpController.java b/src/main/java/com/sumte/security/controller/SignUpController.java index 45a6258..81a344c 100644 --- a/src/main/java/com/sumte/security/controller/SignUpController.java +++ b/src/main/java/com/sumte/security/controller/SignUpController.java @@ -16,7 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -@Tag(name = "회원가입 API", description = "회원가입 API입니다.") +@Tag(name = "회원가입/로그인 API", description = "회원가입/로그인 관련 API입니다.") @RestController @RequestMapping("/users/signup") @RequiredArgsConstructor diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a42ff19..54c594a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -28,6 +28,7 @@ cloud: secret-key: ${S3_SECRET_KEY} s3: bucket: sumte-file + url-prefix: https://sumte-file.s3.ap-northeast-2.amazonaws.com stack: auto: false