diff --git a/src/main/java/org/sopt/solply_server/domain/place/dto/PlaceLatestReviewDto.java b/src/main/java/org/sopt/solply_server/domain/place/dto/PlaceLatestReviewDto.java new file mode 100644 index 00000000..4ed6e15f --- /dev/null +++ b/src/main/java/org/sopt/solply_server/domain/place/dto/PlaceLatestReviewDto.java @@ -0,0 +1,33 @@ +package org.sopt.solply_server.domain.place.dto; + +import java.time.LocalDate; +import java.util.List; +import org.sopt.solply_server.domain.review.entity.PlaceReview; +import org.sopt.solply_server.domain.review.entity.VisitTime; +import org.sopt.solply_server.global.util.s3.ImageUrlProvider; + +public record PlaceLatestReviewDto( + Long reviewId, + Long userId, + String nickname, + String profileImageUrl, + String content, + LocalDate visitedAt, + VisitTime visitTimeSlot, + List imageUrls +) { + public static PlaceLatestReviewDto from(PlaceReview placeReview, ImageUrlProvider imageUrlProvider) { + return new PlaceLatestReviewDto( + placeReview.getId(), + placeReview.getUser().getId(), + placeReview.getUser().getNickname(), + imageUrlProvider.getImageUrl(placeReview.getUser().getProfileImageFileKey()), + placeReview.getContent(), + placeReview.getVisitedAt(), + placeReview.getVisitTimeSlot(), + placeReview.getPlaceReviewImages().stream() + .map(image -> imageUrlProvider.getImageUrl(image.getImageUrl())) + .toList() + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/solply_server/domain/place/dto/response/PlaceDetailsGetResponse.java b/src/main/java/org/sopt/solply_server/domain/place/dto/response/PlaceDetailsGetResponse.java index c2b4beee..130e842e 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/dto/response/PlaceDetailsGetResponse.java +++ b/src/main/java/org/sopt/solply_server/domain/place/dto/response/PlaceDetailsGetResponse.java @@ -3,49 +3,53 @@ import java.util.List; import lombok.Builder; import org.sopt.solply_server.domain.place.dto.PlaceImageInfoDto; +import org.sopt.solply_server.domain.place.dto.PlaceLatestReviewDto; import org.sopt.solply_server.domain.place.dto.SnsLinkDto; import org.sopt.solply_server.domain.place.entity.Place; import org.sopt.solply_server.domain.town.entity.Town; @Builder public record PlaceDetailsGetResponse( - long placeId, - String placeName, - String mainTag, - List optionTags, - String introduction, - List imageInfos, - String address, - String latitude, - String longitude, - String contactNumber, - String openingHours, - List snsLinks, - List placeCheckpoints, - boolean isBookmarked, - long townId, - String townName + long placeId, + String placeName, + String mainTag, + List optionTags, + String introduction, + List imageInfos, + String address, + String latitude, + String longitude, + String contactNumber, + String openingHours, + List snsLinks, + List placeCheckpoints, + boolean isBookmarked, + long townId, + String townName, + List latestReviews ) { - public static PlaceDetailsGetResponse of(Place place, String mainTag, List optionTags, - List placeImageInfos, boolean isBookmarked, Town town) { - return PlaceDetailsGetResponse.builder() - .placeId(place.getId()) - .placeName(place.getName()) - .mainTag(mainTag) - .optionTags(optionTags) - .introduction(place.getIntroduction()) - .imageInfos(placeImageInfos) - .address(place.getAddress()) - .latitude(String.valueOf(place.getLatitude())) - .longitude(String.valueOf(place.getLongitude())) - .contactNumber(place.getContactNumber()) - .openingHours(place.getOpeningHours()) - .snsLinks(SnsLinkDto.toList(place.getSnsLinks())) - .placeCheckpoints(place.getCheckpoints()) - .isBookmarked(isBookmarked) - .townId(town.getId()) - .townName(town.getName()) - .build(); - } + public static PlaceDetailsGetResponse of(Place place, String mainTag, List optionTags, + List placeImageInfos, boolean isBookmarked, Town town, + List latestReviews) { + return PlaceDetailsGetResponse.builder() + .placeId(place.getId()) + .placeName(place.getName()) + .mainTag(mainTag) + .optionTags(optionTags) + .introduction(place.getIntroduction()) + .imageInfos(placeImageInfos) + .address(place.getAddress()) + .latitude(String.valueOf(place.getLatitude())) + .longitude(String.valueOf(place.getLongitude())) + .contactNumber(place.getContactNumber()) + .openingHours(place.getOpeningHours()) + .snsLinks(SnsLinkDto.toList(place.getSnsLinks())) + .placeCheckpoints(place.getCheckpoints()) + .isBookmarked(isBookmarked) + .townId(town.getId()) + .townName(town.getName()) + .latestReviews(latestReviews) + .build(); + } } diff --git a/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java b/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java index 37ba3758..922f1948 100644 --- a/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java +++ b/src/main/java/org/sopt/solply_server/domain/place/service/PlaceService.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.sopt.solply_server.domain.place.dto.PlaceFolderPreviewDto; import org.sopt.solply_server.domain.place.dto.PlaceImageInfoDto; +import org.sopt.solply_server.domain.place.dto.PlaceLatestReviewDto; import org.sopt.solply_server.domain.place.dto.PlaceSearchConditionDto; import org.sopt.solply_server.domain.place.dto.PlacePreviewDto; import org.sopt.solply_server.domain.place.dto.PlaceSearchResultDto; @@ -25,6 +26,7 @@ import org.sopt.solply_server.domain.place.repository.PlaceRepository; import org.sopt.solply_server.domain.place.repository.PlaceTagRepository; import org.sopt.solply_server.domain.place.service.facade.PlaceBookmarkFacade; +import org.sopt.solply_server.domain.review.repository.PlaceReviewRepository; import org.sopt.solply_server.domain.tag.entity.Tag; import org.sopt.solply_server.domain.tag.entity.TagType; import org.sopt.solply_server.domain.tag.util.TagValidator; @@ -46,214 +48,228 @@ @Transactional(readOnly = true) public class PlaceService { - private final PlaceRepository placeRepository; - private final PlaceTagRepository placeTagRepository; - private final ImageUrlProvider imageUrlProvider; - private final TagValidator tagValidator; - private final PlaceBookmarkFacade placeBookmarkFacade; - private final TownValidator townValidator; - private final EntityLoader entityLoader; - - /** - * 장소 상세 정보 조회 - */ - public PlaceDetailsGetResponse getPlaceDetailsById(final Long userId, final Long placeId) { - Place place = entityLoader.getActivePlaceWithTownAndCheckpoints(placeId); - - List imageInfos = place.getPlaceImageInfos().stream() - .map(info -> PlaceImageInfoDto.of( - info.getDisplayOrder(), - imageUrlProvider.getImageUrl(info.getImageFileKey()) - )) - .toList(); - - List tags = placeTagRepository.findAllByPlaceId(placeId).stream() - .map(PlaceTag::getTag) - .toList(); - - String mainTag = tags.stream() - .filter(t -> t.getType() == TagType.MAIN) - .map(Tag::getName) - .findFirst() - .orElse(null); - - List optionTags = tags.stream() - .filter(t -> t.getType() != TagType.MAIN) - .map(Tag::getName) - .toList(); - - boolean isBookmarked = placeBookmarkFacade.isBookmarked( - userId, placeId, place.getTown().getId()); - - Town town = place.getTown(); - - return PlaceDetailsGetResponse.of( - place, - mainTag, - optionTags, - imageInfos, - isBookmarked, - town - ); + private final PlaceRepository placeRepository; + private final PlaceTagRepository placeTagRepository; + private final ImageUrlProvider imageUrlProvider; + private final TagValidator tagValidator; + private final PlaceBookmarkFacade placeBookmarkFacade; + private final TownValidator townValidator; + private final EntityLoader entityLoader; + private final PlaceReviewRepository placeReviewRepository; + + /** + * 장소 상세 정보 조회 + */ + public PlaceDetailsGetResponse getPlaceDetailsById(final Long userId, final Long placeId) { + Place place = entityLoader.getActivePlaceWithTownAndCheckpoints(placeId); + + List imageInfos = place.getPlaceImageInfos().stream() + .map(info -> PlaceImageInfoDto.of( + info.getDisplayOrder(), + imageUrlProvider.getImageUrl(info.getImageFileKey()) + )) + .toList(); + + List tags = placeTagRepository.findAllByPlaceId(placeId).stream() + .map(PlaceTag::getTag) + .toList(); + + String mainTag = tags.stream() + .filter(t -> t.getType() == TagType.MAIN) + .map(Tag::getName) + .findFirst() + .orElse(null); + + List optionTags = tags.stream() + .filter(t -> t.getType() != TagType.MAIN) + .map(Tag::getName) + .toList(); + + boolean isBookmarked = placeBookmarkFacade.isBookmarked( + userId, placeId, place.getTown().getId()); + List latestReviews = placeReviewRepository + .findTop3ByPlaceIdOrderByCreatedAtDesc(placeId) + .stream() + .map(review -> PlaceLatestReviewDto.from(review, imageUrlProvider)) + .toList(); + Town town = place.getTown(); + + return PlaceDetailsGetResponse.of( + place, + mainTag, + optionTags, + imageInfos, + isBookmarked, + town, + latestReviews + ); + } + + /** + * 동네와 태그 조건에 따른 장소 조회 + */ + public PlaceFilterGetResponse getPlacesByTownAndTag( + final Long userId, final Long townId, final Boolean isBookmarkSearch, final Long mainTagId, + final List subTagAIdList, final List subTagBIdList) { + + if (userId == null && Boolean.TRUE.equals(isBookmarkSearch)) { + throw new JwtTokenException(ErrorCode.UNAUTHORIZED_USER); } - /** - * 동네와 태그 조건에 따른 장소 조회 - */ - public PlaceFilterGetResponse getPlacesByTownAndTag( - final Long userId, final Long townId, final Boolean isBookmarkSearch, final Long mainTagId, - final List subTagAIdList, final List subTagBIdList) { - - if (userId == null && Boolean.TRUE.equals(isBookmarkSearch)) { - throw new JwtTokenException(ErrorCode.UNAUTHORIZED_USER); - } - - townValidator.validateTownId(townId); - - boolean isOnlyBookmarkSearch = Boolean.TRUE.equals(isBookmarkSearch); - - // isBookmarkSearch=true 시 orderedIds를 한 번만 조회해서 장소 목록 필터링과 북마크 Set에 재사용 - // (기존에는 getBookmarkedPlacesByLatest()와 북마크 Set 생성 시 각각 1회씩, 총 2회 호출했음) - List bookmarkedOrderedIds = (isOnlyBookmarkSearch && userId != null) - ? placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId) - : null; - - // 북마크 검색 시 orderedIds를 함께 전달 → 내부에서 북마크 최신순으로 재정렬 - List places = getPlacesByCondition(townId, isOnlyBookmarkSearch, mainTagId, subTagAIdList, subTagBIdList, bookmarkedOrderedIds); - - Set bookmarkedIds; - if (bookmarkedOrderedIds != null) { - // 북마크 검색: 이미 조회한 orderedIds를 Set으로 변환해서 재사용 - bookmarkedIds = new HashSet<>(bookmarkedOrderedIds); - } else if (userId != null) { - // 일반 장소 목록 + 로그인 상태: 각 장소 카드의 북마크 여부(하트) 표시를 위해 조회 - bookmarkedIds = new HashSet<>(placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId)); - } else { - // 비로그인: 북마크 여부 불필요 - bookmarkedIds = Collections.emptySet(); - } - - List placePreviewDtoList = places.stream() - .map(place -> PlacePreviewDto.of( - place.getId(), - place.getName(), - imageUrlProvider.getImageUrl(place.getThumbnailFileKey()), - TagViewUtils.getActiveNameOrNull(place.getMainTag().orElse(null)), - bookmarkedIds.contains(place.getId()), - townId - )) - .toList(); - - return PlaceFilterGetResponse.from(placePreviewDtoList); + townValidator.validateTownId(townId); + + boolean isOnlyBookmarkSearch = Boolean.TRUE.equals(isBookmarkSearch); + + // isBookmarkSearch=true 시 orderedIds를 한 번만 조회해서 장소 목록 필터링과 북마크 Set에 재사용 + // (기존에는 getBookmarkedPlacesByLatest()와 북마크 Set 생성 시 각각 1회씩, 총 2회 호출했음) + List bookmarkedOrderedIds = (isOnlyBookmarkSearch && userId != null) + ? placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId) + : null; + + // 북마크 검색 시 orderedIds를 함께 전달 → 내부에서 북마크 최신순으로 재정렬 + List places = getPlacesByCondition(townId, isOnlyBookmarkSearch, mainTagId, + subTagAIdList, subTagBIdList, bookmarkedOrderedIds); + + Set bookmarkedIds; + if (bookmarkedOrderedIds != null) { + // 북마크 검색: 이미 조회한 orderedIds를 Set으로 변환해서 재사용 + bookmarkedIds = new HashSet<>(bookmarkedOrderedIds); + } else if (userId != null) { + // 일반 장소 목록 + 로그인 상태: 각 장소 카드의 북마크 여부(하트) 표시를 위해 조회 + bookmarkedIds = new HashSet<>( + placeBookmarkFacade.getBookmarkedPlaceIdsForTown(userId, townId)); + } else { + // 비로그인: 북마크 여부 불필요 + bookmarkedIds = Collections.emptySet(); } - - /** - * 사용자가 북마크한 장소의 썸네일 리스트 조회 (동네별 최신 1개) - */ - public PlaceFolderPreviewListGetResponse getBookmarkedPlaceFolderPreviewList(final Long userId) { - // 동네별 최신 placeId 맵 (ZREVRANGE 0 0 per town) - Map latestPlaceIdByTown = placeBookmarkFacade.getLatestBookmarkedPlaceIdPerTown(userId); - - if (latestPlaceIdByTown.isEmpty()) { - return PlaceFolderPreviewListGetResponse.from(List.of()); - } - - List placeIds = new ArrayList<>(latestPlaceIdByTown.values()); - List places = entityLoader.getPlacesWithTown(placeIds); - - if (places.isEmpty()) { - return PlaceFolderPreviewListGetResponse.from(List.of()); - } - - List dtos = places.stream() - .map(place -> { - Town town = place.getTown(); - return PlaceFolderPreviewDto.of( - town.getId(), - town.getName(), - imageUrlProvider.getImageUrl(place.getThumbnailFileKey()) - ); - }) - .toList(); - - return PlaceFolderPreviewListGetResponse.from(dtos); - } - - public PlaceSearchResponse searchPlaces(final String keyword) { - if (InputValidator.isBlank(keyword) || keyword.length() < 2) { - throw new BusinessException(ErrorCode.INVALID_KEYWORD); - } - var places = placeRepository.findPlacesWithTownByKeyword(keyword); - List placePreviews = places.stream() - .map(place -> { - Town town = place.getTown(); - return PlaceSearchResultDto.of( - place.getId(), - place.getName(), - imageUrlProvider.getImageUrl(place.getThumbnailFileKey()), - TagViewUtils.getActiveNameOrNull(place.getMainTag().orElse(null)), - place.getAddress(), - false, - town.getId() - ); - } - ) - .toList(); - - return new PlaceSearchResponse(placePreviews); + List placePreviewDtoList = places.stream() + .map(place -> PlacePreviewDto.of( + place.getId(), + place.getName(), + imageUrlProvider.getImageUrl(place.getThumbnailFileKey()), + TagViewUtils.getActiveNameOrNull(place.getMainTag().orElse(null)), + bookmarkedIds.contains(place.getId()), + townId + )) + .toList(); + + return PlaceFilterGetResponse.from(placePreviewDtoList); + } + + + /** + * 사용자가 북마크한 장소의 썸네일 리스트 조회 (동네별 최신 1개) + */ + public PlaceFolderPreviewListGetResponse getBookmarkedPlaceFolderPreviewList(final Long userId) { + // 동네별 최신 placeId 맵 (ZREVRANGE 0 0 per town) + Map latestPlaceIdByTown = placeBookmarkFacade.getLatestBookmarkedPlaceIdPerTown( + userId); + + if (latestPlaceIdByTown.isEmpty()) { + return PlaceFolderPreviewListGetResponse.from(List.of()); } + List placeIds = new ArrayList<>(latestPlaceIdByTown.values()); + List places = entityLoader.getPlacesWithTown(placeIds); - //=== Private Methods ===// + if (places.isEmpty()) { + return PlaceFolderPreviewListGetResponse.from(List.of()); + } - private List getPlacesByCondition(final Long selectedTownId, final boolean isOnlyBookmarkSearch, - final Long mainTagId, final List subTagAIdList, final List subTagBIdList, - final List bookmarkedOrderedIds) { - if (mainTagId != null) { - tagValidator.validatePlaceTagConditions(mainTagId, subTagAIdList, subTagBIdList); - } + List dtos = places.stream() + .map(place -> { + Town town = place.getTown(); + return PlaceFolderPreviewDto.of( + town.getId(), + town.getName(), + imageUrlProvider.getImageUrl(place.getThumbnailFileKey()) + ); + }) + .toList(); + + return PlaceFolderPreviewListGetResponse.from(dtos); + } + + public PlaceSearchResponse searchPlaces(final String keyword) { + if (InputValidator.isBlank(keyword) || keyword.length() < 2) { + throw new BusinessException(ErrorCode.INVALID_KEYWORD); + } + var places = placeRepository.findPlacesWithTownByKeyword(keyword); + List placePreviews = places.stream() + .map(place -> { + Town town = place.getTown(); + return PlaceSearchResultDto.of( + place.getId(), + place.getName(), + imageUrlProvider.getImageUrl(place.getThumbnailFileKey()), + TagViewUtils.getActiveNameOrNull(place.getMainTag().orElse(null)), + place.getAddress(), + false, + town.getId() + ); + } + ) + .toList(); + + return new PlaceSearchResponse(placePreviews); + } + + //=== Private Methods ===// + + private List getPlacesByCondition(final Long selectedTownId, + final boolean isOnlyBookmarkSearch, + final Long mainTagId, final List subTagAIdList, final List subTagBIdList, + final List bookmarkedOrderedIds) { + if (mainTagId != null) { + tagValidator.validatePlaceTagConditions(mainTagId, subTagAIdList, subTagBIdList); + } - if (isOnlyBookmarkSearch) { - return getBookmarkedPlacesByLatest(selectedTownId, mainTagId, subTagAIdList, subTagBIdList, bookmarkedOrderedIds); - } + if (isOnlyBookmarkSearch) { + return getBookmarkedPlacesByLatest(selectedTownId, mainTagId, subTagAIdList, subTagBIdList, + bookmarkedOrderedIds); + } - return placeRepository.findPlacesByConditions( - PlaceSearchConditionDto.of(selectedTownId, false, null, mainTagId, subTagAIdList, subTagBIdList) - ); + return placeRepository.findPlacesByConditions( + PlaceSearchConditionDto.of(selectedTownId, false, null, mainTagId, subTagAIdList, + subTagBIdList) + ); + } + + /** + * 북마크 장소 최신순 조회. 상위에서 조회한 orderedIds(ZSET 최신순) → DB에서 태그 조건 필터링 → ZSET 순서 복원. + */ + private List getBookmarkedPlacesByLatest( + final Long selectedTownId, + final Long mainTagId, + final List subTagAIdList, + final List subTagBIdList, + final List orderedIds + ) { + if (orderedIds == null || orderedIds.isEmpty()) { + return List.of(); } - /** - * 북마크 장소 최신순 조회. - * 상위에서 조회한 orderedIds(ZSET 최신순) → DB에서 태그 조건 필터링 → ZSET 순서 복원. - */ - private List getBookmarkedPlacesByLatest( - final Long selectedTownId, - final Long mainTagId, - final List subTagAIdList, - final List subTagBIdList, - final List orderedIds - ) { - if (orderedIds == null || orderedIds.isEmpty()) return List.of(); - - List filtered = placeRepository.findPlacesByConditions( - PlaceSearchConditionDto.of( - selectedTownId, - true, - orderedIds, - mainTagId, - subTagAIdList, - subTagBIdList - ) - ); - if (filtered.isEmpty()) return List.of(); - - // DB 결과를 ZSET 순서(북마크 최신순)로 재정렬 - Map placeMap = filtered.stream() - .collect(Collectors.toMap(Place::getId, Function.identity())); - return orderedIds.stream() - .map(placeMap::get) - .filter(Objects::nonNull) - .toList(); + List filtered = placeRepository.findPlacesByConditions( + PlaceSearchConditionDto.of( + selectedTownId, + true, + orderedIds, + mainTagId, + subTagAIdList, + subTagBIdList + ) + ); + if (filtered.isEmpty()) { + return List.of(); } + + // DB 결과를 ZSET 순서(북마크 최신순)로 재정렬 + Map placeMap = filtered.stream() + .collect(Collectors.toMap(Place::getId, Function.identity())); + return orderedIds.stream() + .map(placeMap::get) + .filter(Objects::nonNull) + .toList(); + } } diff --git a/src/main/java/org/sopt/solply_server/domain/review/controller/PlaceReviewController.java b/src/main/java/org/sopt/solply_server/domain/review/controller/PlaceReviewController.java index e49e40d9..b2b011ac 100644 --- a/src/main/java/org/sopt/solply_server/domain/review/controller/PlaceReviewController.java +++ b/src/main/java/org/sopt/solply_server/domain/review/controller/PlaceReviewController.java @@ -1,5 +1,8 @@ package org.sopt.solply_server.domain.review.controller; +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; import org.sopt.solply_server.domain.review.dto.request.CreatePlaceReviewRequest; @@ -13,11 +16,16 @@ @RestController @RequiredArgsConstructor +@Tag(name = "리뷰 API", description = "장소 리뷰 관련 API") @RequestMapping("/api/places/reviews") public class PlaceReviewController { private final PlaceReviewService placeReviewService; + @Operation( + summary = "장소 리뷰 작성", + description = "특정 장소에 대한 리뷰를 작성합니다." + ) @PostMapping public ResponseEntity> createRecord( @CurrentUserId Long userId, @@ -27,6 +35,13 @@ public ResponseEntity> createRecord return CustomApiResponse.success("혼놀 기록 작성이 완료되었습니다.", response); } + @Operation( + summary = "장소 리뷰 리스트 조회", + description = "특정 장소 ID에 대한 전체 리뷰 리스트를 조회합니다.", + parameters = { + @Parameter(name = "placeId", description = "조회할 장소 ID", required = true, example = "1") + } + ) @GetMapping("/{placeId}/reviews") public ResponseEntity> getPlaceReviews( @CurrentUserId Long userId, diff --git a/src/main/java/org/sopt/solply_server/domain/review/repository/PlaceReviewRepository.java b/src/main/java/org/sopt/solply_server/domain/review/repository/PlaceReviewRepository.java index a4ef0f17..88c45c52 100644 --- a/src/main/java/org/sopt/solply_server/domain/review/repository/PlaceReviewRepository.java +++ b/src/main/java/org/sopt/solply_server/domain/review/repository/PlaceReviewRepository.java @@ -17,4 +17,5 @@ public interface PlaceReviewRepository extends JpaRepository order by pr.createdAt desc """) List findAllByPlaceIdOrderByCreatedAtDesc(@Param("placeId") Long placeId); + List findTop3ByPlaceIdOrderByCreatedAtDesc(Long placeId); } \ No newline at end of file