View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.app.service;
17  
18  import java.nio.charset.StandardCharsets;
19  import java.time.LocalDateTime;
20  import java.time.ZoneId;
21  import java.time.ZoneOffset;
22  import java.time.format.DateTimeFormatter;
23  import java.util.Base64;
24  import java.util.HashMap;
25  import java.util.LinkedHashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.stream.Collectors;
29  
30  import org.apache.logging.log4j.LogManager;
31  import org.apache.logging.log4j.Logger;
32  import org.codelibs.core.beans.util.BeanUtil;
33  import org.codelibs.core.lang.StringUtil;
34  import org.codelibs.fess.Constants;
35  import org.codelibs.fess.app.pager.SearchLogPager;
36  import org.codelibs.fess.exception.FessSystemException;
37  import org.codelibs.fess.helper.SystemHelper;
38  import org.codelibs.fess.mylasta.direction.FessConfig;
39  import org.codelibs.fess.opensearch.log.allcommon.EsPagingResultBean;
40  import org.codelibs.fess.opensearch.log.cbean.ClickLogCB;
41  import org.codelibs.fess.opensearch.log.cbean.FavoriteLogCB;
42  import org.codelibs.fess.opensearch.log.cbean.SearchLogCB;
43  import org.codelibs.fess.opensearch.log.cbean.UserInfoCB;
44  import org.codelibs.fess.opensearch.log.exbhv.ClickLogBhv;
45  import org.codelibs.fess.opensearch.log.exbhv.FavoriteLogBhv;
46  import org.codelibs.fess.opensearch.log.exbhv.SearchLogBhv;
47  import org.codelibs.fess.opensearch.log.exbhv.UserInfoBhv;
48  import org.codelibs.fess.opensearch.log.exentity.ClickLog;
49  import org.codelibs.fess.opensearch.log.exentity.FavoriteLog;
50  import org.codelibs.fess.opensearch.log.exentity.SearchLog;
51  import org.codelibs.fess.opensearch.log.exentity.UserInfo;
52  import org.codelibs.fess.taglib.FessFunctions;
53  import org.dbflute.optional.OptionalEntity;
54  import org.opensearch.search.aggregations.AggregationBuilders;
55  import org.opensearch.search.aggregations.BucketOrder;
56  import org.opensearch.search.aggregations.bucket.histogram.DateHistogramInterval;
57  import org.opensearch.search.aggregations.bucket.histogram.Histogram;
58  import org.opensearch.search.aggregations.bucket.terms.Terms;
59  import org.opensearch.search.aggregations.metrics.Avg;
60  import org.opensearch.search.aggregations.metrics.Cardinality;
61  
62  import jakarta.annotation.Resource;
63  
64  /**
65   * Service class for managing search logs and related analytics.
66   *
67   * This service provides functionality for querying, aggregating, and managing
68   * various types of search logs including search logs, click logs, favorite logs,
69   * and user information logs. It supports different aggregation types for
70   * analytics and reporting purposes.
71   */
72  public class SearchLogService {
73  
74      /** Date format pattern for parsing time ranges. */
75      private static final String YYYY_MM_DD_HH_MM = "yyyy-MM-dd HH:mm";
76  
77      /** Field name for count values in aggregation results. */
78      private static final String COUNT = "count";
79  
80      /** Field name for key values in aggregation results. */
81      private static final String KEY = "key";
82  
83      /** Field name for ID values in aggregation results. */
84      private static final String ID = "id";
85  
86      /** Field name for user information ID in aggregations. */
87      private static final String USER_INFO_ID = "userInfoId";
88  
89      /** Field name for query time in aggregations. */
90      private static final String QUERY_TIME = "queryTime";
91  
92      /** Logger for this class. */
93      private static final Logger logger = LogManager.getLogger(SearchLogService.class);
94  
95      /** Behavior handler for search log operations. */
96      @Resource
97      private SearchLogBhv searchLogBhv;
98  
99      /** Behavior handler for click log operations. */
100     @Resource
101     private ClickLogBhv clickLogBhv;
102 
103     /** Behavior handler for favorite log operations. */
104     @Resource
105     private FavoriteLogBhv favoriteLogBhv;
106 
107     /** Behavior handler for user information operations. */
108     @Resource
109     private UserInfoBhv userInfoBhv;
110 
111     /** System helper for date/time operations. */
112     @Resource
113     private SystemHelper systemHelper;
114 
115     /** Fess configuration settings. */
116     @Resource
117     protected FessConfig fessConfig;
118 
119     /**
120      * Default constructor for creating a new SearchLogService instance.
121      */
122     public SearchLogService() {
123         // Default constructor
124     }
125 
126     /**
127      * Deletes search logs older than the specified number of days.
128      *
129      * @param days Number of days to keep (logs older than this will be deleted)
130      */
131     public void deleteBefore(final int days) {
132         searchLogBhv.queryDelete(cb -> {
133             cb.query().setRequestedAt_LessEqual(systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
134         });
135     }
136 
137     /**
138      * Deletes click logs older than the specified number of days.
139      *
140      * @param days Number of days to keep (logs older than this will be deleted)
141      */
142     public void deleteClickLogBefore(final int days) {
143         clickLogBhv.queryDelete(cb -> {
144             cb.query().setRequestedAt_LessEqual(systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
145         });
146     }
147 
148     /**
149      * Deletes favorite logs older than the specified number of days.
150      *
151      * @param days Number of days to keep (logs older than this will be deleted)
152      */
153     public void deleteFavoriteLogBefore(final int days) {
154         favoriteLogBhv.queryDelete(cb -> {
155             cb.query().setCreatedAt_LessEqual(systemHelper.getCurrentTimeAsLocalDateTime().minusDays(days));
156         });
157     }
158 
159     /**
160      * Retrieves a list of search logs based on the specified pager criteria.
161      *
162      * This method supports various log types including search logs, click logs,
163      * favorite logs, user information, and different aggregation types for analytics.
164      *
165      * @param pager The search log pager containing filter criteria and pagination settings
166      * @return List of search log entries or aggregated data based on the log type
167      */
168     public List<?> getSearchLogList(final SearchLogPager pager) {
169         final EsPagingResultBean<?> list;
170         if (SearchLogPager.LOG_TYPE_USERINFO.equalsIgnoreCase(pager.logType)) {
171             list = (EsPagingResultBean<?>) userInfoBhv.selectPage(cb -> {
172                 cb.paging(pager.getPageSize(), pager.getCurrentPageNumber());
173                 cb.query().addOrderBy_UpdatedAt_Desc();
174                 createUserInfoCondition(pager, cb);
175             });
176         } else if (SearchLogPager.LOG_TYPE_CLICK.equalsIgnoreCase(pager.logType)) {
177             list = (EsPagingResultBean<?>) clickLogBhv.selectPage(cb -> {
178                 cb.paging(pager.getPageSize(), pager.getCurrentPageNumber());
179                 cb.query().addOrderBy_RequestedAt_Desc();
180                 createClickLogCondition(pager, cb);
181             });
182         } else if (SearchLogPager.LOG_TYPE_CLICK_COUNT.equalsIgnoreCase(pager.logType)) {
183             list = (EsPagingResultBean<?>) clickLogBhv.selectPage(cb -> {
184                 cb.fetchFirst(0);
185                 createClickLogCondition(pager, cb);
186                 cb.aggregation().setUrl_Terms(SearchLogPager.LOG_TYPE_CLICK_COUNT, op -> {
187                     op.size(pager.getPageSize());
188                     if (fessConfig.getSearchlogAggShardSizeAsInteger() >= 0) {
189                         op.shardSize(fessConfig.getSearchlogAggShardSizeAsInteger());
190                     }
191                 }, null);
192             });
193             final Terms agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_CLICK_COUNT);
194             final List<? extends Terms.Bucket> buckets = agg.getBuckets();
195             updatePagerByAgg(pager, buckets.size());
196             return buckets.stream().map(e -> {
197                 final Map<String, Object> map = new HashMap<>();
198                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
199                 map.put(KEY, e.getKeyAsString());
200                 map.put(COUNT, e.getDocCount());
201                 return map;
202             }).collect(Collectors.toList());
203         } else if (SearchLogPager.LOG_TYPE_FAVORITE.equalsIgnoreCase(pager.logType)) {
204             list = (EsPagingResultBean<?>) favoriteLogBhv.selectPage(cb -> {
205                 cb.paging(pager.getPageSize(), pager.getCurrentPageNumber());
206                 cb.query().addOrderBy_CreatedAt_Desc();
207                 createFavoriteLogCondition(pager, cb);
208             });
209         } else if (SearchLogPager.LOG_TYPE_FAVORITE_COUNT.equalsIgnoreCase(pager.logType)) {
210             list = (EsPagingResultBean<?>) favoriteLogBhv.selectPage(cb -> {
211                 cb.fetchFirst(0);
212                 createFavoriteLogCondition(pager, cb);
213                 cb.aggregation().setUrl_Terms(SearchLogPager.LOG_TYPE_FAVORITE_COUNT, op -> {
214                     op.size(pager.getPageSize());
215                     if (fessConfig.getSearchlogAggShardSizeAsInteger() >= 0) {
216                         op.shardSize(fessConfig.getSearchlogAggShardSizeAsInteger());
217                     }
218                 }, null);
219             });
220             final Terms agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_FAVORITE_COUNT);
221             final List<? extends Terms.Bucket> buckets = agg.getBuckets();
222             updatePagerByAgg(pager, buckets.size());
223             return buckets.stream().map(e -> {
224                 final Map<String, Object> map = new HashMap<>();
225                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
226                 map.put(KEY, e.getKeyAsString());
227                 map.put(COUNT, e.getDocCount());
228                 return map;
229             }).collect(Collectors.toList());
230         } else if (SearchLogPager.LOG_TYPE_SEARCH_COUNT_HOUR.equalsIgnoreCase(pager.logType)) {
231             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
232                 cb.fetchFirst(0);
233                 createSearchLogCondition(pager, cb);
234                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_COUNT_HOUR, op -> {
235                     op.calendarInterval(DateHistogramInterval.HOUR);
236                     op.minDocCount(0);
237                     op.order(BucketOrder.key(true));
238                 }, null);
239             });
240             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_COUNT_HOUR);
241             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
242             updatePagerByAgg(pager, buckets.size());
243             return buckets.stream().map(e -> {
244                 final Map<String, Object> map = new HashMap<>();
245                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
246                 map.put(KEY, e.getKeyAsString());
247                 map.put(COUNT, e.getDocCount());
248                 return map;
249             }).collect(Collectors.toList());
250         } else if (SearchLogPager.LOG_TYPE_SEARCH_COUNT_DAY.equalsIgnoreCase(pager.logType)) {
251             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
252                 cb.fetchFirst(0);
253                 createSearchLogCondition(pager, cb);
254                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_COUNT_DAY, op -> {
255                     op.calendarInterval(DateHistogramInterval.DAY);
256                     op.minDocCount(0);
257                     op.order(BucketOrder.key(true));
258                 }, null);
259             });
260             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_COUNT_DAY);
261             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
262             updatePagerByAgg(pager, buckets.size());
263             return buckets.stream().map(e -> {
264                 final Map<String, Object> map = new HashMap<>();
265                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
266                 map.put(KEY, e.getKeyAsString());
267                 map.put(COUNT, e.getDocCount());
268                 return map;
269             }).collect(Collectors.toList());
270         } else if (SearchLogPager.LOG_TYPE_SEARCH_USER_HOUR.equalsIgnoreCase(pager.logType)) {
271             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
272                 cb.fetchFirst(0);
273                 createSearchLogCondition(pager, cb);
274                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_USER_HOUR, op -> {
275                     op.calendarInterval(DateHistogramInterval.HOUR);
276                     op.subAggregation(AggregationBuilders.cardinality(USER_INFO_ID).field(USER_INFO_ID));
277                     op.minDocCount(0);
278                     op.order(BucketOrder.key(true));
279                 }, null);
280             });
281             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_USER_HOUR);
282             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
283             updatePagerByAgg(pager, buckets.size());
284             return buckets.stream().map(e -> {
285                 final Map<String, Object> map = new HashMap<>();
286                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
287                 map.put(KEY, e.getKeyAsString());
288                 final Cardinality value = e.getAggregations().get(USER_INFO_ID);
289                 map.put(COUNT, value.getValue());
290                 return map;
291             }).collect(Collectors.toList());
292         } else if (SearchLogPager.LOG_TYPE_SEARCH_USER_DAY.equalsIgnoreCase(pager.logType)) {
293             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
294                 cb.fetchFirst(0);
295                 createSearchLogCondition(pager, cb);
296                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_USER_DAY, op -> {
297                     op.calendarInterval(DateHistogramInterval.DAY);
298                     op.subAggregation(AggregationBuilders.cardinality(USER_INFO_ID).field(USER_INFO_ID));
299                     op.minDocCount(0);
300                     op.order(BucketOrder.key(true));
301                 }, null);
302             });
303             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_USER_DAY);
304             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
305             updatePagerByAgg(pager, buckets.size());
306             return buckets.stream().map(e -> {
307                 final Map<String, Object> map = new HashMap<>();
308                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
309                 map.put(KEY, e.getKeyAsString());
310                 final Cardinality value = e.getAggregations().get(USER_INFO_ID);
311                 map.put(COUNT, value.getValue());
312                 return map;
313             }).collect(Collectors.toList());
314         } else if (SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_HOUR.equalsIgnoreCase(pager.logType)) {
315             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
316                 cb.fetchFirst(0);
317                 createSearchLogCondition(pager, cb);
318                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_HOUR, op -> {
319                     op.calendarInterval(DateHistogramInterval.HOUR);
320                     op.subAggregation(AggregationBuilders.avg(QUERY_TIME).field(QUERY_TIME));
321                     op.minDocCount(0);
322                     op.order(BucketOrder.key(true));
323                 }, null);
324             });
325             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_HOUR);
326             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
327             updatePagerByAgg(pager, buckets.size());
328             return buckets.stream().map(e -> {
329                 final Map<String, Object> map = new HashMap<>();
330                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
331                 map.put(KEY, e.getKeyAsString());
332                 final Avg value = e.getAggregations().get(QUERY_TIME);
333                 map.put(COUNT, value.getValueAsString());
334                 return map;
335             }).collect(Collectors.toList());
336         } else if (SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_DAY.equalsIgnoreCase(pager.logType)) {
337             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
338                 cb.fetchFirst(0);
339                 createSearchLogCondition(pager, cb);
340                 cb.aggregation().setRequestedAt_DateHistogram(SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_DAY, op -> {
341                     op.calendarInterval(DateHistogramInterval.DAY);
342                     op.subAggregation(AggregationBuilders.avg(QUERY_TIME).field(QUERY_TIME));
343                     op.minDocCount(0);
344                     op.order(BucketOrder.key(true));
345                 }, null);
346             });
347             final Histogram agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_REQTIMEAVG_DAY);
348             final List<? extends Histogram.Bucket> buckets = agg.getBuckets();
349             updatePagerByAgg(pager, buckets.size());
350             return buckets.stream().map(e -> {
351                 final Map<String, Object> map = new HashMap<>();
352                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
353                 map.put(KEY, e.getKeyAsString());
354                 final Avg value = e.getAggregations().get(QUERY_TIME);
355                 map.put(COUNT, value.getValueAsString());
356                 return map;
357             }).collect(Collectors.toList());
358         } else if (SearchLogPager.LOG_TYPE_SEARCH_KEYWORD.equalsIgnoreCase(pager.logType)) {
359             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
360                 cb.fetchFirst(0);
361                 createSearchLogCondition(pager, cb);
362                 cb.aggregation().setSearchWord_Terms(SearchLogPager.LOG_TYPE_SEARCH_KEYWORD, op -> {
363                     op.size(pager.getPageSize());
364                     if (fessConfig.getSearchlogAggShardSizeAsInteger() >= 0) {
365                         op.shardSize(fessConfig.getSearchlogAggShardSizeAsInteger());
366                     }
367                 }, null);
368             });
369             final Terms agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_KEYWORD);
370             final List<? extends Terms.Bucket> buckets = agg.getBuckets();
371             updatePagerByAgg(pager, buckets.size());
372             return buckets.stream().map(e -> {
373                 final Map<String, Object> map = new HashMap<>();
374                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
375                 map.put(KEY, e.getKeyAsString());
376                 map.put(COUNT, e.getDocCount());
377                 return map;
378             }).collect(Collectors.toList());
379         } else if (SearchLogPager.LOG_TYPE_SEARCH_ZEROHIT.equalsIgnoreCase(pager.logType)) {
380             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
381                 cb.fetchFirst(0);
382                 createSearchLogCondition(pager, cb);
383                 cb.query().setHitCount_Equal(0L);
384                 cb.aggregation().setSearchWord_Terms(SearchLogPager.LOG_TYPE_SEARCH_ZEROHIT, op -> {
385                     op.size(pager.getPageSize());
386                     if (fessConfig.getSearchlogAggShardSizeAsInteger() >= 0) {
387                         op.shardSize(fessConfig.getSearchlogAggShardSizeAsInteger());
388                     }
389                 }, null);
390             });
391             final Terms agg = list.getAggregations().get(SearchLogPager.LOG_TYPE_SEARCH_ZEROHIT);
392             final List<? extends Terms.Bucket> buckets = agg.getBuckets();
393             updatePagerByAgg(pager, buckets.size());
394             return buckets.stream().map(e -> {
395                 final Map<String, Object> map = new HashMap<>();
396                 map.put(ID, Base64.getUrlEncoder().encodeToString(e.getKeyAsString().getBytes(StandardCharsets.UTF_8)));
397                 map.put(KEY, e.getKeyAsString());
398                 map.put(COUNT, e.getDocCount());
399                 return map;
400             }).collect(Collectors.toList());
401             //        } else if (SearchLogPager.LOG_TYPE_SEARCH_ZEROCLICK.equalsIgnoreCase(pager.logType)) {
402             //            list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
403             //                cb.fetchFirst(0);
404             //                createSearchLogCondition(pager, cb);
405             //                // TODO 0 clicked
406             //                });
407         } else {
408             list = (EsPagingResultBean<?>) searchLogBhv.selectPage(cb -> {
409                 cb.paging(pager.getPageSize(), pager.getCurrentPageNumber());
410                 cb.query().addOrderBy_RequestedAt_Desc();
411                 createSearchLogCondition(pager, cb);
412             });
413         }
414 
415         // update pager
416         BeanUtil.copyBeanToBean(list, pager, option -> option.include(Constants.PAGER_CONVERSION_RULE));
417         pager.setPageNumberList(list.pageRange(op -> {
418             op.rangeSize(fessConfig.getPagingPageRangeSizeAsInteger());
419         }).createPageNumberList());
420 
421         return list;
422     }
423 
424     /**
425      * Updates the pager with aggregation result information.
426      *
427      * @param pager The search log pager to update
428      * @param size The size of the aggregation results
429      */
430     private void updatePagerByAgg(final SearchLogPager pager, final int size) {
431         pager.setAllPageCount(1);
432         pager.setAllRecordCount(size);
433         pager.setCurrentPageNumber(1);
434         pager.setExistNextPage(false);
435         pager.setExistPrePage(false);
436         pager.setPageSize(pager.getPageSize());
437     }
438 
439     /**
440      * Creates search conditions for search log queries based on pager criteria.
441      *
442      * @param pager The search log pager containing filter criteria
443      * @param cb The search log condition bean to configure
444      */
445     private void createSearchLogCondition(final SearchLogPager pager, final SearchLogCB cb) {
446         if (StringUtil.isNotBlank(pager.queryId)) {
447             cb.query().setQueryId_Term(pager.queryId);
448         }
449         if (StringUtil.isNotBlank(pager.userSessionId)) {
450             cb.query().setUserSessionId_Term(pager.userSessionId);
451         }
452         if (StringUtil.isNotBlank(pager.accessType)) {
453             cb.query().setAccessType_Term(pager.accessType);
454         }
455         if (StringUtil.isNotBlank(pager.requestedTimeRange)) {
456             final String[] values = pager.requestedTimeRange.split(" - ");
457             final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(YYYY_MM_DD_HH_MM);
458             try {
459                 if (values.length > 0) {
460                     cb.query().setRequestedAt_GreaterEqual(parseDateTime(values[0], formatter));
461                 }
462                 if (values.length > 1) {
463                     cb.query().setRequestedAt_LessEqual(LocalDateTime.parse(values[1], formatter));
464                 }
465             } catch (final Exception e) {
466                 if (logger.isDebugEnabled()) {
467                     logger.debug("Failed to parse {}", pager.requestedTimeRange, e);
468                 }
469             }
470         }
471     }
472 
473     /**
474      * Creates search conditions for favorite log queries based on pager criteria.
475      *
476      * @param pager The search log pager containing filter criteria
477      * @param cb The favorite log condition bean to configure
478      */
479     private void createFavoriteLogCondition(final SearchLogPager pager, final FavoriteLogCB cb) {
480         if (StringUtil.isNotBlank(pager.queryId)) {
481             cb.query().setQueryId_Term(pager.queryId);
482         }
483         if (StringUtil.isNotBlank(pager.userSessionId)) {
484             cb.query().setUserInfoId_Term(pager.userSessionId);
485         }
486         if (StringUtil.isNotBlank(pager.requestedTimeRange)) {
487             final String[] values = pager.requestedTimeRange.split(" - ");
488             final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(YYYY_MM_DD_HH_MM);
489             try {
490                 if (values.length > 0) {
491                     cb.query().setCreatedAt_GreaterEqual(LocalDateTime.parse(values[0], formatter));
492                 }
493                 if (values.length > 1) {
494                     cb.query().setCreatedAt_LessEqual(parseDateTime(values[1], formatter));
495                 }
496             } catch (final Exception e) {
497                 if (logger.isDebugEnabled()) {
498                     logger.debug("Failed to parse {}", pager.requestedTimeRange, e);
499                 }
500             }
501         }
502     }
503 
504     /**
505      * Creates search conditions for user info queries based on pager criteria.
506      *
507      * @param pager The search log pager containing filter criteria
508      * @param cb The user info condition bean to configure
509      */
510     private void createUserInfoCondition(final SearchLogPager pager, final UserInfoCB cb) {
511         if (StringUtil.isNotBlank(pager.userSessionId)) {
512             cb.query().setId_Equal(pager.userSessionId);
513         }
514         if (StringUtil.isNotBlank(pager.requestedTimeRange)) {
515             final String[] values = pager.requestedTimeRange.split(" - ");
516             final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(YYYY_MM_DD_HH_MM);
517             try {
518                 if (values.length > 0) {
519                     cb.query().setUpdatedAt_GreaterEqual(LocalDateTime.parse(values[0], formatter));
520                 }
521                 if (values.length > 1) {
522                     cb.query().setUpdatedAt_LessEqual(LocalDateTime.parse(values[1], formatter));
523                 }
524             } catch (final Exception e) {
525                 if (logger.isDebugEnabled()) {
526                     logger.debug("Failed to parse {}", pager.requestedTimeRange, e);
527                 }
528             }
529         }
530     }
531 
532     /**
533      * Creates search conditions for click log queries based on pager criteria.
534      *
535      * @param pager The search log pager containing filter criteria
536      * @param cb The click log condition bean to configure
537      */
538     private void createClickLogCondition(final SearchLogPager pager, final ClickLogCB cb) {
539         if (StringUtil.isNotBlank(pager.queryId)) {
540             cb.query().setQueryId_Term(pager.queryId);
541         }
542         if (StringUtil.isNotBlank(pager.userSessionId)) {
543             cb.query().setUserSessionId_Term(pager.userSessionId);
544         }
545         if (StringUtil.isNotBlank(pager.requestedTimeRange)) {
546             final String[] values = pager.requestedTimeRange.split(" - ");
547             final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(YYYY_MM_DD_HH_MM);
548             try {
549                 if (values.length > 0) {
550                     cb.query().setRequestedAt_GreaterEqual(LocalDateTime.parse(values[0], formatter));
551                 }
552                 if (values.length > 1) {
553                     cb.query().setRequestedAt_LessEqual(LocalDateTime.parse(values[1], formatter));
554                 }
555             } catch (final Exception e) {
556                 if (logger.isDebugEnabled()) {
557                     logger.debug("Failed to parse {}", pager.requestedTimeRange, e);
558                 }
559             }
560         }
561     }
562 
563     /**
564      * Parses a date/time string and converts it to UTC timezone.
565      *
566      * @param value The date/time string to parse
567      * @param formatter The date/time formatter to use
568      * @return LocalDateTime in UTC timezone
569      */
570     protected LocalDateTime parseDateTime(final String value, final DateTimeFormatter formatter) {
571         return LocalDateTime.parse(value, formatter).atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
572     }
573 
574     /**
575      * Retrieves a specific search log entry by log type and ID.
576      *
577      * @param logType The type of log to retrieve (search, click, favorite, userinfo)
578      * @param id The ID of the log entry
579      * @return Optional entity containing the log entry if found
580      */
581     public OptionalEntity<?> getSearchLog(final String logType, final String id) {
582         if (SearchLogPager.LOG_TYPE_CLICK.equalsIgnoreCase(logType)) {
583             return clickLogBhv.selectByPK(id);
584         }
585         if (SearchLogPager.LOG_TYPE_FAVORITE.equalsIgnoreCase(logType)) {
586             return favoriteLogBhv.selectByPK(id);
587         }
588         if (SearchLogPager.LOG_TYPE_USERINFO.equalsIgnoreCase(logType)) {
589             return userInfoBhv.selectByPK(id);
590         }
591         return searchLogBhv.selectByPK(id);
592     }
593 
594     /**
595      * Retrieves a search log entry as a formatted map of field names and values.
596      *
597      * @param logType The type of log to retrieve (search, click, favorite, userinfo)
598      * @param id The ID of the log entry
599      * @return Map containing formatted field names and values for display
600      */
601     public Map<String, String> getSearchLogMap(final String logType, final String id) {
602         if (SearchLogPager.LOG_TYPE_USERINFO.equalsIgnoreCase(logType)) {
603             return userInfoBhv.selectByPK(id).map(e -> {
604                 final Map<String, String> params = new LinkedHashMap<>();
605                 params.put("User Info ID", e.getId());
606                 params.put("Created Time", FessFunctions.formatDate(e.getCreatedAt()));
607                 params.put("Updated Time", FessFunctions.formatDate(e.getUpdatedAt()));
608                 return params;
609             }).get();
610         }
611         if (SearchLogPager.LOG_TYPE_CLICK.equalsIgnoreCase(logType)) {
612             return clickLogBhv.selectByPK(id).map(e -> {
613                 final Map<String, String> params = new LinkedHashMap<>();
614                 params.put("ID", e.getId());
615                 params.put("Query ID", e.getQueryId());
616                 params.put("Doc ID", e.getDocId());
617                 params.put("User Session ID", e.getUserSessionId());
618                 params.put("URL", e.getUrl());
619                 params.put("URL ID", e.getUrlId());
620                 params.put("Order", toNumberString(e.getOrder()));
621                 params.put("Query Requested Time", FessFunctions.formatDate(e.getQueryRequestedAt()));
622                 params.put("Requested Time", FessFunctions.formatDate(e.getRequestedAt()));
623                 return params;
624             }).get();
625         }
626         if (SearchLogPager.LOG_TYPE_FAVORITE.equalsIgnoreCase(logType)) {
627             return favoriteLogBhv.selectByPK(id).map(e -> {
628                 final Map<String, String> params = new LinkedHashMap<>();
629                 params.put("ID", e.getId());
630                 params.put("Query ID", e.getQueryId());
631                 params.put("Doc ID", e.getDocId());
632                 params.put("User Info ID", e.getUserInfoId());
633                 params.put("URL", e.getUrl());
634                 params.put("Created Time", FessFunctions.formatDate(e.getCreatedAt()));
635                 params.put("Requested Time", FessFunctions.formatDate(e.getRequestedAt()));
636                 return params;
637             }).get();
638         }
639         return searchLogBhv.selectByPK(id).map(e -> {
640             final Map<String, String> params = new LinkedHashMap<>();
641             params.put("ID", e.getId());
642             params.put("Query ID", e.getQueryId());
643             params.put("User Info ID", e.getUserInfoId());
644             params.put("User Session ID", e.getUserSessionId());
645             params.put("Access Type", e.getAccessType());
646             params.put("Search Word", e.getSearchWord());
647             params.put("Requested Time", FessFunctions.formatDate(e.getRequestedAt()));
648             params.put("Query Time", toNumberString(e.getQueryTime()));
649             params.put("Response Time", toNumberString(e.getResponseTime()));
650             params.put("Hit Count", toNumberString(e.getHitCount()));
651             params.put("Offset", toNumberString(e.getQueryOffset()));
652             params.put("Page Size", toNumberString(e.getQueryPageSize()));
653             params.put("Client IP", e.getClientIp());
654             params.put("Referer", e.getReferer());
655             params.put("Languages", e.getLanguages());
656             params.put("Virtual Host", e.getVirtualHost());
657             params.put("Roles", e.getRoles() != null ? String.join(" ", e.getRoles()) : StringUtil.EMPTY);
658             params.put("User Agent", e.getUserAgent());
659             e.getSearchFieldLogList().stream().forEach(p -> {
660                 params.put(p.getFirst(), p.getSecond());
661             });
662             e.getRequestHeaderList().stream().forEach(p -> {
663                 params.put(p.getFirst(), p.getSecond());
664             });
665             return params;
666         }).get();
667     }
668 
669     /**
670      * Converts a number to its string representation, handling null values.
671      *
672      * @param value The number to convert
673      * @return String representation of the number, or empty string if null
674      */
675     private String toNumberString(final Number value) {
676         return value != null ? value.toString() : StringUtil.EMPTY;
677     }
678 
679     /**
680      * Deletes a search log entry based on its type.
681      *
682      * @param e The log entity to delete (ClickLog, FavoriteLog, UserInfo, or SearchLog)
683      * @throws FessSystemException if the entity type is not recognized
684      */
685     public void deleteSearchLog(final Object e) {
686         if (e instanceof final ClickLog clickLog) {
687             clickLogBhv.delete(clickLog);
688         } else if (e instanceof final FavoriteLog favoriteLog) {
689             favoriteLogBhv.delete(favoriteLog);
690         } else if (e instanceof final UserInfo userInfo) {
691             userInfoBhv.delete(userInfo);
692         } else if (e instanceof final SearchLog searchLog) {
693             searchLogBhv.delete(searchLog);
694         } else {
695             throw new FessSystemException("Unknown log entity: " + e);
696         }
697     }
698 }