diff --git a/src/main/java/com/comma/soomteum/domain/place/dto/TourApiRequestDto.java b/src/main/java/com/comma/soomteum/domain/place/dto/TourApiRequestDto.java index ee39c4e..94467a5 100644 --- a/src/main/java/com/comma/soomteum/domain/place/dto/TourApiRequestDto.java +++ b/src/main/java/com/comma/soomteum/domain/place/dto/TourApiRequestDto.java @@ -46,6 +46,7 @@ public static class AreaBasedList2 { private String cat1; private String cat2; private Integer contentTypeId; + private String keyword; // 검색어 (장소명 필터링) @Builder.Default private Integer pageNo = 1; @Builder.Default private Integer numOfRows = 20; diff --git a/src/main/java/com/comma/soomteum/domain/tour/controller/TourController.java b/src/main/java/com/comma/soomteum/domain/tour/controller/TourController.java index 241c109..745cded 100644 --- a/src/main/java/com/comma/soomteum/domain/tour/controller/TourController.java +++ b/src/main/java/com/comma/soomteum/domain/tour/controller/TourController.java @@ -52,9 +52,25 @@ public Flux locationRecommendPlaces( summary = "지역 기반 한적한 장소 추천", description = """ 각 장소의 한적함 점수(cnctrRate)를 포함하여 반환합니다. - - `cnctrRate`는 관광객 혼잡도를 나타내는 지수로, -1은 데이터가 없음을 의미합니다. - - 정렬(arrange) 기본값: O=제목순, Q=수정일순, R=등록일순, S=거리순 - - `cat1`, `cat2`를 통해 장소 분류 코드로 필터링할 수 있습니다. + + **검색 기능:** + - `keyword`: 장소명으로 검색 (부분 일치, 대소문자 무관) + - 검색어가 없으면 전체 결과 반환 + + **정렬 옵션:** + - `arrange=A`: AI 추천순 (기본값) - 종합적인 추천 알고리즘 사용 + - `arrange=C`: 한적함순 - cnctrRate 높은 순으로 정렬 (AI 정렬 건너뜀) + - `arrange=Q`: 수정일순 + - `arrange=R`: 등록일순 + + **기타 필터:** + - `cnctrRate`: 관광객 혼잡도 지수 (-1은 데이터 없음) + - `cat1`, `cat2`: 장소 분류 코드로 필터링 + + **예시:** + - `/api/places?areaCode=1&keyword=바다` - "바다" 포함 장소 검색 + - `/api/places?areaCode=1&arrange=C` - 한적함순 정렬 + - `/api/places?areaCode=1&keyword=카페&arrange=C` - "카페" 검색 + 한적함순 """, responses = { @ApiResponse(responseCode = "200", description = "성공", diff --git a/src/main/java/com/comma/soomteum/domain/tour/service/TourService.java b/src/main/java/com/comma/soomteum/domain/tour/service/TourService.java index b5562ce..c70acee 100644 --- a/src/main/java/com/comma/soomteum/domain/tour/service/TourService.java +++ b/src/main/java/com/comma/soomteum/domain/tour/service/TourService.java @@ -79,40 +79,73 @@ public Flux locationPlaces(TourApiReques public Flux AreaPlaces(TourApiRequestDto.AreaBasedList2 request) { return korAreaService.areaBasedList(request) .flatMapMany(response -> Flux.fromIterable(response.getBody().getItems().getItem())) + // 검색 필터링 + .filter(place -> { + if (request.getKeyword() == null || request.getKeyword().isBlank()) { + return true; // 검색어 없으면 모두 통과 + } + return place.getTitle().toLowerCase() + .contains(request.getKeyword().toLowerCase()); + }) .flatMap(this::addCnctrRateToPlace) .collectList() - .flatMap(candidateList -> Mono.fromCallable(() -> { + .flatMapMany(candidateList -> { + // 한적함순 정렬 + if ("C".equals(request.getArrange())) { + candidateList.sort((a, b) -> { + double rateA = parseCnctrRate(a.getCnctrRate()); + double rateB = parseCnctrRate(b.getCnctrRate()); + return Double.compare(rateB, rateA); // 내림차순 (높은 값 = 한적함) + }); - List aiRequestItems = candidateList.stream() - .map(dto -> new AiRecommendationRequest( - dto.getTitle(), - dto.getContentid(), - dto.getCat1(), - dto.getCat2(), - dto.getFirstimage(), - dto.getDist(), - dto.getCnctrRate() - )) - .collect(Collectors.toList()); + // AI 정렬 건너뛰고 바로 반환 + return Flux.fromIterable(candidateList) + .map(dto -> { + // quietnessLevel 계산 (AI 정렬을 안 거치므로 직접 계산) + double rate = parseCnctrRate(dto.getCnctrRate()); + int quietnessLevel = calculateQuietnessLevel(rate); + dto.setQuietnessLevel(quietnessLevel); - return aiServiceAdapter.createRankedRecommendations(aiRequestItems); - })) - .flatMapMany(Flux::fromIterable) - .map(aiResponse -> { - TatsCnctrResponse.TatsCnctrResponseDto finalDto = new TatsCnctrResponse.TatsCnctrResponseDto(); - finalDto.setTitle(aiResponse.getTitle()); - finalDto.setContentid(aiResponse.getContentid()); - finalDto.setCat1(aiResponse.getCat1()); - finalDto.setCat2(aiResponse.getCat2()); - finalDto.setFirstimage(aiResponse.getFirstimage()); - finalDto.setDist(aiResponse.getDist()); - finalDto.setCnctrRate(aiResponse.getCnctrRate()); - finalDto.setQuietnessLevel(aiResponse.getQuietnessLevel()); - - // 새로운 필드들 설정 - setCatNameAndAreaInfo(finalDto, request.getAreaCode(), request.getSigunguCode()); - - return finalDto; + // 새로운 필드들 설정 + setCatNameAndAreaInfo(dto, request.getAreaCode(), request.getSigunguCode()); + + return dto; + }); + } + + // 기존 AI 정렬 로직 + return Mono.fromCallable(() -> { + List aiRequestItems = candidateList.stream() + .map(dto -> new AiRecommendationRequest( + dto.getTitle(), + dto.getContentid(), + dto.getCat1(), + dto.getCat2(), + dto.getFirstimage(), + dto.getDist(), + dto.getCnctrRate() + )) + .collect(Collectors.toList()); + + return aiServiceAdapter.createRankedRecommendations(aiRequestItems); + }) + .flatMapMany(Flux::fromIterable) + .map(aiResponse -> { + TatsCnctrResponse.TatsCnctrResponseDto finalDto = new TatsCnctrResponse.TatsCnctrResponseDto(); + finalDto.setTitle(aiResponse.getTitle()); + finalDto.setContentid(aiResponse.getContentid()); + finalDto.setCat1(aiResponse.getCat1()); + finalDto.setCat2(aiResponse.getCat2()); + finalDto.setFirstimage(aiResponse.getFirstimage()); + finalDto.setDist(aiResponse.getDist()); + finalDto.setCnctrRate(aiResponse.getCnctrRate()); + finalDto.setQuietnessLevel(aiResponse.getQuietnessLevel()); + + // 새로운 필드들 설정 + setCatNameAndAreaInfo(finalDto, request.getAreaCode(), request.getSigunguCode()); + + return finalDto; + }); }); } @@ -156,23 +189,57 @@ private void setCatNameAndAreaInfo(TatsCnctrResponse.TatsCnctrResponseDto dto, I themeRepository.findByCat1AndCat2(dto.getCat1(), dto.getCat2()) .ifPresent(theme -> dto.setCatName(theme.getName())); } - + // likeCount 설정 if (dto.getContentid() != null) { placeService.findByContentId(dto.getContentid()) .ifPresent(place -> dto.setLikeCount(place.getLikeCount())); } - + // areaCode, sigunguCode, areaName 설정 if (areaCode != null && sigunguCode != null) { dto.setAreaCode(areaCode); dto.setSigunguCode(sigunguCode); - + // areaName 설정 regionRepository.findByKorAreaCodeAndKorSigunguCode( - String.valueOf(areaCode), + String.valueOf(areaCode), String.valueOf(sigunguCode) ).ifPresent(region -> dto.setAreaName(region.getName())); } } + + /** + * cnctrRate 문자열을 double로 변환 + * @param cnctrRate 혼잡도 문자열 + * @return 파싱된 혼잡도 값. 실패 시 -1.0 반환 + */ + private double parseCnctrRate(String cnctrRate) { + try { + return Double.parseDouble(cnctrRate); + } catch (NumberFormatException | NullPointerException e) { + return -1.0; // 데이터 없는 경우 최하위 + } + } + + /** + * cnctrRate 값을 기반으로 한적함 등급 계산 + * @param rate 혼잡도 값 + * @return 한적함 등급 (1-5, -1은 데이터 없음) + */ + private int calculateQuietnessLevel(double rate) { + if (rate < 0) { + return -1; // 데이터 없음 + } else if (rate <= 20.0) { + return 1; // 혼잡 + } else if (rate <= 40.0) { + return 2; // 약간 혼잡 + } else if (rate <= 60.0) { + return 3; // 보통 + } else if (rate <= 80.0) { + return 4; // 한적함 + } else { + return 5; // 매우 한적함 + } + } } \ No newline at end of file