|
|
@@ -26,7 +26,6 @@ import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.Optional;
|
|
|
-import java.util.PriorityQueue;
|
|
|
import java.util.Set;
|
|
|
import java.util.TreeSet;
|
|
|
import java.util.concurrent.Executor;
|
|
|
@@ -59,6 +58,7 @@ public class Customer144CatalogService {
|
|
|
return size() > QUERY_TRANSLATION_CACHE_SIZE;
|
|
|
}
|
|
|
});
|
|
|
+ private final Map<String, SearchCacheEntry> searchResultCache = new LinkedHashMap<>(16, 0.75f, true);
|
|
|
private volatile Instant lastFullSync;
|
|
|
|
|
|
public Customer144CatalogService(Customer144Properties properties, Customer144Client client,
|
|
|
@@ -125,11 +125,13 @@ public class Customer144CatalogService {
|
|
|
.items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
|
|
|
.filterOptions(ProductService.filterOptions(browseItems)).build();
|
|
|
}
|
|
|
- int required = page > Integer.MAX_VALUE / pageSize ? Integer.MAX_VALUE : page * pageSize;
|
|
|
+ String cacheKey = searchCacheKey(request, parsedQueries, fuzzyLookup);
|
|
|
+ SearchCacheEntry cached = cachedSearch(cacheKey);
|
|
|
+ if (cached != null) return cachedPage(cached, page, pageSize);
|
|
|
+
|
|
|
Comparator<ScoredProduct> bestFirst = comparator(request.getSort(), hasQuery)
|
|
|
.thenComparing(item -> item.getProduct().getId());
|
|
|
- PriorityQueue<ScoredProduct> best = new PriorityQueue<>(Math.max(1, required), bestFirst.reversed());
|
|
|
- long[] total = {0};
|
|
|
+ List<ScoredProduct> matches = new ArrayList<>();
|
|
|
Set<String> visualOptions = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
|
|
Set<String> yearOptions = new TreeSet<>(Comparator.reverseOrder());
|
|
|
for (Customer144Product source : candidates(parsedQueries, fuzzyLookup)) {
|
|
|
@@ -140,16 +142,9 @@ public class Customer144CatalogService {
|
|
|
if (hasQuery && score <= 0) continue;
|
|
|
collectFilterOptions(product, visualOptions, yearOptions);
|
|
|
if (!matchesFilters(product, request.getFilters())) continue;
|
|
|
- total[0]++;
|
|
|
- ScoredProduct candidate = new ScoredProduct(product, score, matchedIndex, orderScore);
|
|
|
- if (best.size() < required) {
|
|
|
- best.offer(candidate);
|
|
|
- } else if (required > 0 && bestFirst.compare(candidate, best.peek()) < 0) {
|
|
|
- best.poll();
|
|
|
- best.offer(candidate);
|
|
|
- }
|
|
|
+ matches.add(new ScoredProduct(source.getId(), product, score, matchedIndex, orderScore));
|
|
|
}
|
|
|
- if (!hasQuery && total[0] == 0 && !hasActiveFilters(request.getFilters())) {
|
|
|
+ if (!hasQuery && matches.isEmpty() && !hasActiveFilters(request.getFilters())) {
|
|
|
Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize);
|
|
|
List<ProductResponse> browseItems = browsePage.getItems().stream()
|
|
|
.map(mapper::toProduct).collect(Collectors.toList());
|
|
|
@@ -157,19 +152,104 @@ public class Customer144CatalogService {
|
|
|
.items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
|
|
|
.filterOptions(ProductService.filterOptions(browseItems)).build();
|
|
|
}
|
|
|
- List<ScoredProduct> ordered = new ArrayList<>(best);
|
|
|
- ordered.sort(bestFirst);
|
|
|
- int from = Math.min((page - 1) * pageSize, ordered.size());
|
|
|
- List<ProductResponse> items = ordered.subList(from, ordered.size()).stream()
|
|
|
+ matches.sort(bestFirst);
|
|
|
+ int from = pageOffset(page, pageSize, matches.size());
|
|
|
+ int to = Math.min(from + pageSize, matches.size());
|
|
|
+ List<ProductResponse> items = matches.subList(from, to).stream()
|
|
|
.map(ScoredProduct::getProduct).collect(Collectors.toList());
|
|
|
Map<String, List<String>> filterOptions = new LinkedHashMap<>();
|
|
|
filterOptions.put("visual", new ArrayList<>(visualOptions));
|
|
|
filterOptions.put("year", new ArrayList<>(yearOptions));
|
|
|
+ List<Long> orderedIds = matches.stream().map(ScoredProduct::getRemoteId).collect(Collectors.toList());
|
|
|
+ cacheSearch(cacheKey, orderedIds, filterOptions);
|
|
|
return PaginatedResponse.<ProductResponse>builder()
|
|
|
- .items(items).total(total[0]).page(page).pageSize(pageSize)
|
|
|
+ .items(items).total(matches.size()).page(page).pageSize(pageSize)
|
|
|
.filterOptions(filterOptions).build();
|
|
|
}
|
|
|
|
|
|
+ private PaginatedResponse<ProductResponse> cachedPage(SearchCacheEntry cached, int page, int pageSize) {
|
|
|
+ int from = pageOffset(page, pageSize, cached.getOrderedIds().size());
|
|
|
+ int to = Math.min(from + pageSize, cached.getOrderedIds().size());
|
|
|
+ List<Long> pageIds = cached.getOrderedIds().subList(from, to);
|
|
|
+ List<ProductResponse> items = snapshotStore.findAllByIds(pageIds).stream()
|
|
|
+ .map(mapper::toProduct).collect(Collectors.toList());
|
|
|
+ return PaginatedResponse.<ProductResponse>builder()
|
|
|
+ .items(items).total(cached.getOrderedIds().size()).page(page).pageSize(pageSize)
|
|
|
+ .filterOptions(cached.getFilterOptions()).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ private int pageOffset(int page, int pageSize, int total) {
|
|
|
+ long offset = (long) Math.max(page - 1, 0) * pageSize;
|
|
|
+ return (int) Math.min(offset, total);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String searchCacheKey(ProductSearchRequest request,
|
|
|
+ List<ProductNameSearch.ParsedQuery> parsedQueries,
|
|
|
+ boolean fuzzyLookup) {
|
|
|
+ StringBuilder key = new StringBuilder(fuzzyLookup ? "fuzzy" : "exact");
|
|
|
+ for (ProductNameSearch.ParsedQuery query : parsedQueries) {
|
|
|
+ appendCachePart(key, query.getNormalized());
|
|
|
+ for (String term : query.getTerms()) appendCachePart(key, term);
|
|
|
+ if (fuzzyLookup) {
|
|
|
+ for (String term : query.getLookupTerms()) appendCachePart(key, term);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ appendCachePart(key, request.getSort());
|
|
|
+ Map<String, List<String>> filters = request.getFilters();
|
|
|
+ if (filters != null) {
|
|
|
+ List<String> filterNames = new ArrayList<>(filters.keySet());
|
|
|
+ Collections.sort(filterNames);
|
|
|
+ for (String filterName : filterNames) {
|
|
|
+ appendCachePart(key, filterName);
|
|
|
+ List<String> values = filters.get(filterName);
|
|
|
+ if (values == null) continue;
|
|
|
+ List<String> sortedValues = new ArrayList<>(values);
|
|
|
+ sortedValues.sort(String.CASE_INSENSITIVE_ORDER);
|
|
|
+ for (String value : sortedValues) appendCachePart(key, value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return key.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void appendCachePart(StringBuilder key, String value) {
|
|
|
+ String safe = value == null ? "" : value.trim();
|
|
|
+ key.append('|').append(safe.length()).append(':').append(safe);
|
|
|
+ }
|
|
|
+
|
|
|
+ private SearchCacheEntry cachedSearch(String key) {
|
|
|
+ long now = System.currentTimeMillis();
|
|
|
+ synchronized (searchResultCache) {
|
|
|
+ SearchCacheEntry cached = searchResultCache.get(key);
|
|
|
+ if (cached == null) return null;
|
|
|
+ if (cached.getExpiresAt() > now) return cached;
|
|
|
+ searchResultCache.remove(key);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void cacheSearch(String key, List<Long> orderedIds,
|
|
|
+ Map<String, List<String>> filterOptions) {
|
|
|
+ int maxEntries = Math.max(properties.getSearchCacheSize(), 0);
|
|
|
+ long ttlMillis = properties.getSearchCacheTtl() == null
|
|
|
+ ? 0L : Math.max(properties.getSearchCacheTtl().toMillis(), 0L);
|
|
|
+ if (maxEntries == 0 || ttlMillis == 0L) return;
|
|
|
+ SearchCacheEntry entry = new SearchCacheEntry(orderedIds, filterOptions,
|
|
|
+ System.currentTimeMillis() + ttlMillis);
|
|
|
+ synchronized (searchResultCache) {
|
|
|
+ searchResultCache.put(key, entry);
|
|
|
+ while (searchResultCache.size() > maxEntries) {
|
|
|
+ String eldest = searchResultCache.keySet().iterator().next();
|
|
|
+ searchResultCache.remove(eldest);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void clearSearchCache() {
|
|
|
+ synchronized (searchResultCache) {
|
|
|
+ searchResultCache.clear();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private void collectFilterOptions(ProductResponse product, Set<String> visuals, Set<String> years) {
|
|
|
if (product.getTags() != null && product.getTags().getVisual() != null) {
|
|
|
product.getTags().getVisual().stream()
|
|
|
@@ -324,6 +404,7 @@ public class Customer144CatalogService {
|
|
|
log.info("客户144商品今日增量同步完成,updated_after={},新增或更新 {} 条",
|
|
|
updatedAfter, persisted);
|
|
|
}
|
|
|
+ clearSearchCache();
|
|
|
lastFullSync = Instant.now();
|
|
|
} catch (Exception exception) {
|
|
|
log.warn("客户144商品快照同步失败,本轮已分页落库 {} 条,未执行旧商品清理: {}",
|
|
|
@@ -717,19 +798,43 @@ public class Customer144CatalogService {
|
|
|
}
|
|
|
|
|
|
private static final class ScoredProduct {
|
|
|
+ private final Long remoteId;
|
|
|
private final ProductResponse product;
|
|
|
private final int relevance;
|
|
|
private final int firstMatchedTermIndex;
|
|
|
private final int termOrder;
|
|
|
- private ScoredProduct(ProductResponse product, int relevance, int firstMatchedTermIndex, int termOrder) {
|
|
|
+ private ScoredProduct(Long remoteId, ProductResponse product, int relevance,
|
|
|
+ int firstMatchedTermIndex, int termOrder) {
|
|
|
+ this.remoteId = remoteId;
|
|
|
this.product = product;
|
|
|
this.relevance = relevance;
|
|
|
this.firstMatchedTermIndex = firstMatchedTermIndex;
|
|
|
this.termOrder = termOrder;
|
|
|
}
|
|
|
+ private Long getRemoteId() { return remoteId; }
|
|
|
private ProductResponse getProduct() { return product; }
|
|
|
private int getRelevance() { return relevance; }
|
|
|
private int getFirstMatchedTermIndex() { return firstMatchedTermIndex; }
|
|
|
private int getTermOrder() { return termOrder; }
|
|
|
}
|
|
|
+
|
|
|
+ private static final class SearchCacheEntry {
|
|
|
+ private final List<Long> orderedIds;
|
|
|
+ private final Map<String, List<String>> filterOptions;
|
|
|
+ private final long expiresAt;
|
|
|
+
|
|
|
+ private SearchCacheEntry(List<Long> orderedIds, Map<String, List<String>> filterOptions,
|
|
|
+ long expiresAt) {
|
|
|
+ this.orderedIds = Collections.unmodifiableList(new ArrayList<>(orderedIds));
|
|
|
+ Map<String, List<String>> copiedOptions = new LinkedHashMap<>();
|
|
|
+ filterOptions.forEach((name, values) -> copiedOptions.put(name,
|
|
|
+ Collections.unmodifiableList(new ArrayList<>(values))));
|
|
|
+ this.filterOptions = Collections.unmodifiableMap(copiedOptions);
|
|
|
+ this.expiresAt = expiresAt;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Long> getOrderedIds() { return orderedIds; }
|
|
|
+ private Map<String, List<String>> getFilterOptions() { return filterOptions; }
|
|
|
+ private long getExpiresAt() { return expiresAt; }
|
|
|
+ }
|
|
|
}
|