11package com .deliveranything .domain .search .store .repository ;
22
3+ import co .elastic .clients .elasticsearch ._types .DistanceUnit ;
4+ import co .elastic .clients .elasticsearch ._types .GeoDistanceType ;
35import co .elastic .clients .elasticsearch ._types .SortOrder ;
46import co .elastic .clients .elasticsearch ._types .query_dsl .BoolQuery ;
57import co .elastic .clients .elasticsearch ._types .query_dsl .Query ;
68import com .deliveranything .domain .search .store .document .StoreDocument ;
79import com .deliveranything .domain .search .store .dto .StoreSearchRequest ;
810import com .deliveranything .global .common .CursorPageResponse ;
911import com .deliveranything .global .util .CursorUtil ;
12+ import java .util .Arrays ;
1013import java .util .List ;
1114import java .util .stream .Collectors ;
1215import lombok .RequiredArgsConstructor ;
@@ -25,78 +28,119 @@ public class StoreSearchRepositoryImpl implements StoreSearchRepositoryCustom {
2528
2629 private final ElasticsearchOperations elasticsearchOperations ;
2730
28- // 검색에 사용할 필드명은 상수로 관리
2931 private static final String FIELD_NAME = "name" ;
3032 private static final String FIELD_DESCRIPTION = "description" ;
3133 private static final String FIELD_KEYWORDS = "keywords" ;
3234 private static final String FIELD_CATEGORY_ID = "category_id" ;
3335 private static final String FIELD_LOCATION = "location" ;
34- private static final String SORT_SCORE = "_score" ;
35- private static final String SORT_ID = "_id" ;
36+ private static final String SORT_ID = "id" ;
3637
3738 @ Override
3839 public CursorPageResponse <StoreDocument > search (StoreSearchRequest request ) {
3940 int querySize = request .limit () + 1 ;
4041
42+ // 검색 조건 구성
4143 BoolQuery .Builder boolQueryBuilder = new BoolQuery .Builder ();
42-
43- if (StringUtils .hasText (request .searchText ())) {
44- boolQueryBuilder .must (m -> m
45- .multiMatch (mm -> mm
46- .query (request .searchText ())
47- .fields (FIELD_NAME , FIELD_DESCRIPTION , FIELD_KEYWORDS )
48- ));
49- }
50-
51- if (request .categoryId () != null ) {
52- boolQueryBuilder .filter (f -> f
53- .term (t -> t
54- .field (FIELD_CATEGORY_ID )
55- .value (request .categoryId ())
56- ));
57- }
58-
59- boolQueryBuilder .filter (f -> f
60- .geoDistance (g -> g
61- .field (FIELD_LOCATION )
62- .location (l -> l .latlon (ll -> ll .lat (request .lat ()).lon (request .lng ())))
63- .distance (request .distanceKm () + "km" )
64- ));
44+ applyFilters (boolQueryBuilder , request );
6545
6646 NativeQueryBuilder queryBuilder = new NativeQueryBuilder ()
6747 .withQuery (Query .of (q -> q .bool (boolQueryBuilder .build ())))
68- .withPageable (PageRequest .of (0 , querySize ))
69- .withSort (s -> s .field (f -> f .field (SORT_SCORE ).order (SortOrder .Desc )))
70- .withSort (s -> s .field (f -> f .field (SORT_ID ).order (SortOrder .Asc )));
71-
72- if (StringUtils .hasText (request .nextPageToken ())) {
73- try {
74- String [] decodedCursor = CursorUtil .decode (request .nextPageToken ());
75- if (decodedCursor != null ) {
76- queryBuilder .withSearchAfter (List .of (decodedCursor ));
77- }
78- } catch (IllegalArgumentException e ) {
79- // 유효하지 않은 토큰일 경우 로그를 남기고 첫 페이지를 반환
80- System .err .println ("Invalid next page token: " + e .getMessage ());
48+ .withPageable (PageRequest .of (0 , querySize ));
49+
50+ // 정렬 조건
51+ if (request .lat () != null && request .lng () != null ) {
52+ queryBuilder
53+ .withSort (s -> s .geoDistance (g -> g
54+ .field (FIELD_LOCATION )
55+ .location (l -> l .latlon (ll -> ll .lat (request .lat ()).lon (request .lng ())))
56+ .unit (DistanceUnit .Kilometers )
57+ .distanceType (GeoDistanceType .Arc )
58+ .order (SortOrder .Asc )));
59+ }
60+
61+ // _id 정렬 또한 알 수 없는 이유로 실패하여, 가장 안정적인 _doc 순서로 정렬;;
62+ queryBuilder .withSort (s -> s .field (f -> f .field ("_doc" ).order (SortOrder .Asc )));
63+
64+ // 커서(search_after) 처리
65+ if (StringUtils .hasText (request .nextPageToken ())) {
66+ Object [] decodedCursor = CursorUtil .decode (request .nextPageToken ());
67+ if (decodedCursor != null ) {
68+ Object [] safeCursor = Arrays .stream (decodedCursor )
69+ .map (value -> {
70+ if (value instanceof Number ) return value ;
71+ if (value instanceof String s ) return s ;
72+ return value ;
73+ })
74+ .toArray ();
75+ queryBuilder .withSearchAfter (Arrays .asList (safeCursor ));
8176 }
8277 }
8378
8479 NativeQuery searchQuery = queryBuilder .build ();
85- SearchHits <StoreDocument > searchHits = elasticsearchOperations .search (searchQuery , StoreDocument .class );
8680
87- List <StoreDocument > documents = searchHits .getSearchHits ().stream ()
88- .map (SearchHit ::getContent ).collect (Collectors .toList ());
81+ // 검색 실행
82+ SearchHits <StoreDocument > searchHits ;
83+ try {
84+ searchHits = elasticsearchOperations .search (searchQuery , StoreDocument .class );
85+ } catch (Exception e ) {
86+ throw new RuntimeException ("Failed to search store documents. Check logs for mapping or query errors." , e );
87+ }
88+
89+ List <SearchHit <StoreDocument >> hits = searchHits .getSearchHits ();
90+ List <StoreDocument > documents = hits .stream ()
91+ .map (SearchHit ::getContent )
92+ .collect (Collectors .toList ());
8993
94+ // 다음 페이지 여부 확인
9095 boolean hasNext = documents .size () > request .limit ();
91- List <StoreDocument > responseDocuments = hasNext ? documents .subList (0 , request .limit ()) : documents ;
96+ List <StoreDocument > responseDocuments = hasNext
97+ ? documents .subList (0 , request .limit ())
98+ : documents ;
9299
100+ // 다음 페이지 토큰 생성
93101 String nextToken = null ;
94- if (hasNext ) {
95- SearchHit <StoreDocument > lastHit = searchHits .getSearchHits ().get (responseDocuments .size () - 1 );
96- // _score, _id 순서에 맞게 정렬 값을 배열로 전달
97- nextToken = CursorUtil .encode (lastHit .getSortValues ().toArray ());
102+ if (hasNext ) {
103+ SearchHit <StoreDocument > lastHit = hits .get (request .limit () - 1 );
104+ Object [] lastSortValues = lastHit .getSortValues ().stream ()
105+ .map (value -> {
106+ if (value instanceof Number ) return value ;
107+ if (value instanceof String s ) return s ;
108+ return value ;
109+ })
110+ .toArray ();
111+ nextToken = CursorUtil .encode (lastSortValues );
98112 }
99113
100114 return new CursorPageResponse <>(responseDocuments , nextToken , hasNext );
101115 }
116+
117+ // 검색 필터 구성
118+ private void applyFilters (BoolQuery .Builder boolQueryBuilder , StoreSearchRequest request ) {
119+ if (StringUtils .hasText (request .searchText ())) {
120+ boolQueryBuilder .must (m -> m .multiMatch (mm -> mm
121+ .query (request .searchText ())
122+ .fields (FIELD_NAME , FIELD_DESCRIPTION , FIELD_KEYWORDS )
123+ ));
124+ }
125+
126+ if (request .categoryId () != null ) {
127+ boolQueryBuilder .filter (f -> f .term (t -> t
128+ .field (FIELD_CATEGORY_ID )
129+ .value (request .categoryId ())));
130+ }
131+
132+ if (request .lat () != null && request .lng () != null ) {
133+ if (request .distanceKm () != null ) {
134+ boolQueryBuilder .filter (f -> f .geoDistance (g -> g
135+ .field (FIELD_LOCATION )
136+ .location (l -> l .latlon (ll -> ll .lat (request .lat ()).lon (request .lng ())))
137+ .distance (request .distanceKm () + "km" )
138+ .distanceType (GeoDistanceType .Arc )
139+ ));
140+ } else {
141+ // 거리순 정렬 시 location 필드가 없는 도큐먼트는 오류를 유발할 수 있으므로, 필드가 존재하는 도큐먼트만 필터링
142+ boolQueryBuilder .filter (f -> f .exists (e -> e .field (FIELD_LOCATION )));
143+ }
144+ }
145+ }
102146}
0 commit comments