package com.singsong.customer144; import com.singsong.config.AsyncConfig; import com.singsong.dto.PaginatedResponse; import com.singsong.dto.ProductResponse; import com.singsong.dto.ProductSearchRequest; import com.singsong.search.ProductNameSearch; import com.singsong.service.ProductService; import com.singsong.service.SimilarityService; import javax.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDateTime; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @Slf4j @Service public class Customer144CatalogService { private static final Pattern HAN_PATTERN = Pattern.compile("[\\p{IsHan}]"); private static final int QUERY_TRANSLATION_CACHE_SIZE = 500; private static final long QUERY_TRANSLATION_FAILURE_CACHE_MILLIS = 5 * 60 * 1000L; private static final Map LOCAL_QUERY_TRANSLATIONS = localQueryTranslations(); private final Customer144Properties properties; private final Customer144Client client; private final Customer144ProductMapper mapper; private final Customer144SnapshotStore snapshotStore; private final SimilarityService similarityService; private final Customer144TranslationService translationService; private final Executor syncExecutor; private final AtomicBoolean syncing = new AtomicBoolean(false); private final Map queryTranslationCache = Collections.synchronizedMap( new LinkedHashMap(128, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > QUERY_TRANSLATION_CACHE_SIZE; } }); private final Map searchResultCache = new LinkedHashMap<>(16, 0.75f, true); private volatile Instant lastFullSync; public Customer144CatalogService(Customer144Properties properties, Customer144Client client, Customer144ProductMapper mapper, Customer144SnapshotStore snapshotStore, SimilarityService similarityService, Customer144TranslationService translationService, @Qualifier(AsyncConfig.CUSTOMER144_SYNC_EXECUTOR) Executor syncExecutor) { this.properties = properties; this.client = client; this.mapper = mapper; this.snapshotStore = snapshotStore; this.similarityService = similarityService; this.translationService = translationService; this.syncExecutor = syncExecutor; } @PostConstruct void initialize() { if (!properties.isConfigured()) return; long snapshotCount = snapshotStore.count(); log.info("已加载客户144商品数据库快照,共 {} 条", snapshotCount); snapshotStore.stats(); if (snapshotCount > 0) { startSync(LocalDateTime.now().toLocalDate().atStartOfDay(), false); } else { startSync(null, true); } } @Scheduled(fixedDelayString = "#{@customer144Properties.refreshInterval.toMillis()}") public void scheduledSync() { startSync(LocalDateTime.now().toLocalDate().atStartOfDay(), false); } public Customer144SnapshotStore.SnapshotStats stats() { return snapshotStore.stats(); } public Optional> search(ProductSearchRequest request) { if (!properties.isConfigured()) { return Optional.empty(); } List parsedQueries = queryVariants(request.getQuery()); int page = request.getPage() == null || request.getPage() < 1 ? 1 : request.getPage(); int pageSize = request.getPageSize() == null || request.getPageSize() < 1 ? 8 : Math.min(request.getPageSize(), 100); PaginatedResponse result = scanFiltered(request, parsedQueries, page, pageSize, false); if (result.getTotal() == 0 && hasFuzzyLookup(parsedQueries)) { result = scanFiltered(request, parsedQueries, page, pageSize, true); } return Optional.of(result); } private PaginatedResponse scanFiltered(ProductSearchRequest request, List parsedQueries, int page, int pageSize, boolean fuzzyLookup) { boolean hasQuery = parsedQueries.stream().anyMatch(query -> !query.getTerms().isEmpty()); if (!hasQuery && !hasActiveFilters(request.getFilters()) && !hasNonDefaultSort(request.getSort())) { Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize); List browseItems = browsePage.getItems().stream() .map(mapper::toProduct).collect(Collectors.toList()); return PaginatedResponse.builder() .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize) .filterOptions(ProductService.filterOptions(browseItems)).build(); } String cacheKey = searchCacheKey(request, parsedQueries, fuzzyLookup); SearchCacheEntry cached = cachedSearch(cacheKey); if (cached != null) return cachedPage(cached, page, pageSize); Comparator bestFirst = comparator(request.getSort(), hasQuery) .thenComparing(item -> item.getProduct().getId()); List matches = new ArrayList<>(); Set visualOptions = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); Set yearOptions = new TreeSet<>(Comparator.reverseOrder()); for (Customer144Product source : candidates(parsedQueries, fuzzyLookup)) { ProductResponse product = mapper.toProduct(source); int score = hasQuery ? relevance(parsedQueries, product) : 0; int matchedIndex = hasQuery ? firstMatchedTermIndex(parsedQueries, product) : 0; int orderScore = hasQuery ? termOrder(parsedQueries, product) : 0; if (hasQuery && score <= 0) continue; collectFilterOptions(product, visualOptions, yearOptions); if (!matchesFilters(product, request.getFilters())) continue; matches.add(new ScoredProduct(source.getId(), product, score, matchedIndex, orderScore)); } if (!hasQuery && matches.isEmpty() && !hasActiveFilters(request.getFilters())) { Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize); List browseItems = browsePage.getItems().stream() .map(mapper::toProduct).collect(Collectors.toList()); return PaginatedResponse.builder() .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize) .filterOptions(ProductService.filterOptions(browseItems)).build(); } matches.sort(bestFirst); int from = pageOffset(page, pageSize, matches.size()); int to = Math.min(from + pageSize, matches.size()); List items = matches.subList(from, to).stream() .map(ScoredProduct::getProduct).collect(Collectors.toList()); Map> filterOptions = new LinkedHashMap<>(); filterOptions.put("visual", new ArrayList<>(visualOptions)); filterOptions.put("year", new ArrayList<>(yearOptions)); List orderedIds = matches.stream().map(ScoredProduct::getRemoteId).collect(Collectors.toList()); cacheSearch(cacheKey, orderedIds, filterOptions); return PaginatedResponse.builder() .items(items).total(matches.size()).page(page).pageSize(pageSize) .filterOptions(filterOptions).build(); } private PaginatedResponse 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 pageIds = cached.getOrderedIds().subList(from, to); List items = snapshotStore.findAllByIds(pageIds).stream() .map(mapper::toProduct).collect(Collectors.toList()); return PaginatedResponse.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 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> filters = request.getFilters(); if (filters != null) { List filterNames = new ArrayList<>(filters.keySet()); Collections.sort(filterNames); for (String filterName : filterNames) { appendCachePart(key, filterName); List values = filters.get(filterName); if (values == null) continue; List 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 orderedIds, Map> 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 visuals, Set years) { if (product.getTags() != null && product.getTags().getVisual() != null) { product.getTags().getVisual().stream() .filter(value -> value != null && !value.trim().isEmpty() && !"-".equals(value.trim())) .map(String::trim).forEach(visuals::add); } if (product.getListedYear() != null) years.add(product.getListedYear().toString()); } public Optional findById(String id) { Long remoteId = remoteId(id); return remoteId == null ? Optional.empty() : snapshotStore.findById(remoteId).map(mapper::toProduct); } public List findSimilar(String id, int limit) { return findSimilar(id, limit, null); } public List findSimilar(String id, int limit, String query) { Long remoteId = remoteId(id); ProductResponse target = findById(id).orElse(null); if (target == null || remoteId == null) return Collections.emptyList(); int resultLimit = limit <= 0 ? 5 : limit; if (hasText(query)) { if (ProductNameSearch.parse(query).getTerms().isEmpty()) return Collections.emptyList(); ProductSearchRequest request = new ProductSearchRequest(); request.setQuery(query.trim()); request.setPage(1); request.setPageSize(recommendationPoolSize(resultLimit)); List candidates = search(request) .map(PaginatedResponse::getItems).orElseGet(Collections::emptyList) .stream().filter(product -> !id.equals(product.getId())) .collect(Collectors.toList()); return randomSample(candidates, resultLimit); } List candidates = snapshotStore.similarPool().stream() .filter(product -> !remoteId.equals(product.getId())) .map(mapper::toProduct) .map(product -> new AbstractMap.SimpleEntry<>(product, similarityService.score(target, product))) .sorted((left, right) -> Integer.compare(right.getValue(), left.getValue())) .filter(entry -> entry.getValue() > 0) .limit(recommendationPoolSize(resultLimit)) .map(Map.Entry::getKey).collect(Collectors.toList()); return randomSample(candidates, resultLimit); } private int recommendationPoolSize(int resultLimit) { return (int) Math.min(Math.max((long) resultLimit * 10, 50L), 100L); } private List randomSample(List candidates, int resultLimit) { List shuffled = new ArrayList<>(candidates); Collections.shuffle(shuffled); return shuffled.stream().limit(resultLimit).collect(Collectors.toList()); } public boolean isRemoteId(String id) { return id != null && id.startsWith(Customer144ProductMapper.ID_PREFIX); } void startFullSync() { startSync(null, true); } private void startSync(LocalDateTime updatedAfter, boolean replaceAll) { if (!properties.isConfigured() || !syncing.compareAndSet(false, true)) return; try { syncExecutor.execute(() -> { try { syncAllPages(updatedAfter, replaceAll); } finally { syncing.set(false); } }); } catch (RuntimeException exception) { syncing.set(false); log.warn("客户144商品快照同步任务提交失败: {}", exception.getMessage()); } } void syncAllPages() { syncAllPages(null, true); } void syncUpdatedToday() { syncAllPages(LocalDateTime.now().toLocalDate().atStartOfDay(), false); } private void syncAllPages(LocalDateTime updatedAfter, boolean replaceAll) { int limit = Math.min(Math.max(properties.getPageSize(), 1), 500); int offset = 0; int pageNumber = 0; long persisted = 0L; LocalDateTime syncedAt = LocalDateTime.now(); Set seenRemoteIds = new HashSet<>(); Set brands = new HashSet<>(); Set productTypes = new HashSet<>(); try { while (true) { Customer144Page page = client.fetchPage(limit, offset, updatedAfter); if (page == null || page.getData() == null || page.getCount() == null) { throw new IllegalStateException("客户144接口响应不完整"); } List data = page.getData(); if (page.getCount() != data.size()) { throw new IllegalStateException("客户144接口count按本页条数校验时与data数量不一致"); } if (page.getOffset() != null && page.getOffset() != offset) { throw new IllegalStateException("客户144接口offset不连续"); } if (page.getLimit() != null && page.getLimit() != limit) { throw new IllegalStateException("客户144接口limit不一致"); } for (Customer144Product product : data) { if (product == null || product.getId() == null) throw new IllegalStateException("客户144商品ID为空"); if (!seenRemoteIds.add(product.getId())) { throw new IllegalStateException("客户144商品ID重复: " + product.getId()); } } if (replaceAll) { snapshotStore.upsertSnapshotPage(data, syncedAt); for (Customer144Product product : data) { if (hasText(product.getBrand())) brands.add(product.getBrand().trim()); if (hasText(product.getProductType())) { productTypes.add(product.getProductType().trim()); } } } else { snapshotStore.mergeSnapshot(data, syncedAt); } persisted += data.size(); pageNumber++; if (pageNumber == 1 || pageNumber % 20 == 0 || data.size() < limit) { log.info("客户144商品{}同步分页落库,页数={},本轮已落库={} 条", replaceAll ? "全量" : "增量", pageNumber, persisted); } if (data.size() < limit) break; offset += data.size(); try { // 页间稍作停顿,避免高频请求触发接口方限流(503 SERVICE_BUSY) Thread.sleep(200L); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException("客户144商品同步已中断", interrupted); } } if (replaceAll) { int removed = snapshotStore.completeFullSync(syncedAt, persisted, brands.size(), productTypes.size()); log.info("客户144商品全量同步完成,共 {} 条,清理失效旧商品 {} 条", persisted, removed); } else { log.info("客户144商品今日增量同步完成,updated_after={},新增或更新 {} 条", updatedAfter, persisted); } clearSearchCache(); lastFullSync = Instant.now(); } catch (Exception exception) { log.warn("客户144商品快照同步失败,本轮已分页落库 {} 条,未执行旧商品清理: {}", persisted, exception.getMessage()); } } private List queryVariants(String rawQuery) { ProductNameSearch.ParsedQuery original = ProductNameSearch.parse(rawQuery); List variants = new ArrayList<>(); variants.add(original); if (!original.getNormalized().isEmpty() && HAN_PATTERN.matcher(original.getNormalized()).find()) { String translated = translateQueryToEnglish(original.getNormalized()); if (hasText(translated)) { ProductNameSearch.ParsedQuery english = ProductNameSearch.parse(translated); if (!english.getTerms().isEmpty() && !english.getNormalized().equals(original.getNormalized())) { variants.add(english); } } } return variants; } private String translateQueryToEnglish(String query) { long now = System.currentTimeMillis(); synchronized (queryTranslationCache) { QueryTranslationCacheEntry cached = queryTranslationCache.get(query); if (cached != null && !cached.isExpired(now)) return cached.getTranslation(); if (cached != null) queryTranslationCache.remove(query); } String localTranslation = localQueryTranslation(query); if (hasText(localTranslation)) { cacheQueryTranslation(query, localTranslation, Long.MAX_VALUE); return localTranslation; } String translated = translationService.translateToEnglishOrNull(query); if (!hasText(translated)) { cacheQueryTranslation(query, null, now + QUERY_TRANSLATION_FAILURE_CACHE_MILLIS); return null; } String normalized = translated.trim(); cacheQueryTranslation(query, normalized, Long.MAX_VALUE); return normalized; } private void cacheQueryTranslation(String query, String translation, long expiresAt) { synchronized (queryTranslationCache) { queryTranslationCache.put(query, new QueryTranslationCacheEntry(translation, expiresAt)); } } private static Map localQueryTranslations() { Map translations = new LinkedHashMap<>(); translations.put("礼品盒子", "gift box"); translations.put("礼品盒", "gift box"); translations.put("礼品袋", "gift bag"); translations.put("圣诞老人", "santa"); translations.put("圣诞树", "christmas tree"); translations.put("桌上摆件", "tabletop decor"); translations.put("墙上装饰", "wall decor"); translations.put("墙面装饰", "wall decor"); translations.put("户外装饰", "outdoor decor"); translations.put("室内装饰", "indoor decor"); translations.put("感恩节", "thanksgiving"); translations.put("情人节", "valentine"); translations.put("母亲节", "mother day"); translations.put("万圣节", "halloween"); translations.put("复活节", "easter"); translations.put("圣诞节", "christmas"); translations.put("生日礼物", "birthday gift"); translations.put("礼盒", "gift box"); translations.put("礼品", "gift"); translations.put("礼物", "gift"); translations.put("盒子", "box"); translations.put("箱子", "box"); translations.put("袋子", "bag"); translations.put("篮子", "basket"); translations.put("收纳", "storage"); translations.put("装饰品", "decor"); translations.put("装饰", "decor"); translations.put("摆件", "figurine"); translations.put("挂件", "ornament"); translations.put("花环", "wreath"); translations.put("花瓶", "vase"); translations.put("花", "flower"); translations.put("茶壶", "teapot"); translations.put("茶杯", "teacup"); translations.put("茶", "tea"); translations.put("水晶", "crystal"); translations.put("水", "water"); translations.put("托盘", "tray"); translations.put("烛台", "candle holder"); translations.put("蜡烛", "candle"); translations.put("灯笼", "lantern"); translations.put("相框", "photo frame"); translations.put("杯子", "mug"); translations.put("枕头", "pillow"); translations.put("毯子", "blanket"); translations.put("头戴式耳机", "headphones"); translations.put("耳机", "headphones"); translations.put("耳麦", "headset"); translations.put("圣诞", "christmas"); translations.put("生日", "birthday"); translations.put("婚礼", "wedding"); translations.put("秋季", "fall"); translations.put("春季", "spring"); translations.put("夏季", "summer"); translations.put("冬季", "winter"); translations.put("南瓜", "pumpkin"); translations.put("雪人", "snowman"); translations.put("驯鹿", "reindeer"); translations.put("麋鹿", "deer"); translations.put("兔子", "bunny"); translations.put("松鼠", "squirrel"); translations.put("卡车", "truck"); translations.put("向日葵", "sunflower"); translations.put("枫叶", "maple leaf"); translations.put("星星", "star"); translations.put("爱心", "heart"); translations.put("雪花", "snowflake"); translations.put("红色", "red"); translations.put("白色", "white"); translations.put("黑色", "black"); translations.put("绿色", "green"); translations.put("蓝色", "blue"); translations.put("粉色", "pink"); translations.put("紫色", "purple"); translations.put("橙色", "orange"); translations.put("金色", "gold"); translations.put("银色", "silver"); translations.put("彩色", "multicolor"); translations.put("金属", "metal"); translations.put("木制", "wood"); translations.put("木质", "wood"); translations.put("玻璃", "glass"); translations.put("陶瓷", "ceramic"); translations.put("布艺", "fabric"); translations.put("塑料", "plastic"); return Collections.unmodifiableMap(translations); } private String localQueryTranslation(String query) { LinkedHashSet translatedTerms = new LinkedHashSet<>(); for (int index = 0; index < query.length();) { Map.Entry best = null; for (Map.Entry entry : LOCAL_QUERY_TRANSLATIONS.entrySet()) { if (!query.startsWith(entry.getKey(), index)) continue; if (best == null || entry.getKey().length() > best.getKey().length()) best = entry; } if (best == null) { index++; continue; } Collections.addAll(translatedTerms, best.getValue().split("\\s+")); index += best.getKey().length(); } return translatedTerms.isEmpty() ? null : String.join(" ", translatedTerms); } private List candidates(List parsedQueries, boolean fuzzyLookup) { Map merged = new LinkedHashMap<>(); for (ProductNameSearch.ParsedQuery query : parsedQueries) { List> groups = candidateTermGroups(query, Collections.emptyMap(), fuzzyLookup); for (Customer144Product product : snapshotStore.candidates(groups)) { if (product != null && product.getId() != null) { merged.putIfAbsent(product.getId(), product); } } } return new ArrayList<>(merged.values()); } private int relevance(List queries, ProductResponse product) { int best = 0; for (ProductNameSearch.ParsedQuery query : queries) { best = Math.max(best, ProductNameSearch.relevance( query, product.getNameCn(), product.getNameEn())); } return best; } private int firstMatchedTermIndex(List queries, ProductResponse product) { int best = Integer.MAX_VALUE; for (ProductNameSearch.ParsedQuery query : queries) { best = Math.min(best, ProductNameSearch.firstMatchedTermIndex( query, product.getNameCn(), product.getNameEn())); } return best; } private int termOrder(List queries, ProductResponse product) { int best = 0; for (ProductNameSearch.ParsedQuery query : queries) { best = Math.max(best, ProductNameSearch.termOrder( query, product.getNameCn(), product.getNameEn())); } return best; } private boolean hasFuzzyLookup(List parsedQueries) { return parsedQueries.stream().anyMatch(query -> !new LinkedHashSet<>(query.getTerms()) .equals(new LinkedHashSet<>(query.getLookupTerms()))); } private boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } private static final class QueryTranslationCacheEntry { private final String translation; private final long expiresAt; private QueryTranslationCacheEntry(String translation, long expiresAt) { this.translation = translation; this.expiresAt = expiresAt; } private String getTranslation() { return translation; } private boolean isExpired(long now) { return now >= expiresAt; } } private List> candidateTermGroups(ProductNameSearch.ParsedQuery parsedQuery, Map> filters, boolean fuzzyLookup) { List> groups = new ArrayList<>(); if (!parsedQuery.getTerms().isEmpty()) { List lookupTerms = fuzzyLookup ? parsedQuery.getLookupTerms() : parsedQuery.getTerms(); groups.add(new ArrayList<>(new LinkedHashSet<>(lookupTerms))); } if (filters == null) return groups; for (Map.Entry> entry : filters.entrySet()) { if (entry.getValue() == null || entry.getValue().isEmpty() || "price".equals(entry.getKey())) continue; LinkedHashSet terms = new LinkedHashSet<>(); for (String value : entry.getValue()) { addCandidateTerm(terms, value); String alias = filterAlias(entry.getKey(), value); addCandidateTerm(terms, alias); } if (!terms.isEmpty()) groups.add(new ArrayList<>(terms)); } return groups; } private void addCandidateTerm(LinkedHashSet terms, String value) { if (value != null && !value.trim().isEmpty()) terms.add(value.trim()); } private String filterAlias(String key, String value) { switch (key) { case "season": return seasonAlias(value); case "visual": return visualAlias(value); case "function": return functionAlias(value); default: return null; } } private boolean hasActiveFilters(Map> filters) { return filters != null && filters.values().stream() .anyMatch(values -> values != null && !values.isEmpty()); } private boolean hasNonDefaultSort(String sort) { return sort != null && !sort.trim().isEmpty() && !"相关度".equals(sort); } private boolean matchesFilters(ProductResponse product, Map> filters) { if (filters == null || filters.isEmpty()) return true; for (Map.Entry> entry : filters.entrySet()) { List values = entry.getValue(); if (values == null || values.isEmpty()) continue; boolean matches; switch (entry.getKey()) { case "series": matches = values.stream().anyMatch(value -> containsIgnoreCase(product.getTags().getSeries(), value)); break; case "season": matches = values.stream().anyMatch(value -> matchesTextFilter(product, value, seasonAlias(value))); break; case "visual": matches = values.stream().anyMatch(value -> matchesTextFilter(product, value, visualAlias(value))); break; case "function": matches = values.stream().anyMatch(value -> matchesTextFilter(product, value, functionAlias(value))); break; case "material": matches = values.stream().anyMatch(value -> containsIgnoreCase(product.getTags().getMaterial(), value)); break; case "price": matches = product.getPrice() != null && inPriceRange(product.getPrice(), values); break; case "year": matches = inYearRange(product, values); break; default: matches = true; } if (!matches) return false; } return true; } private boolean inPriceRange(BigDecimal price, List values) { for (String range : values) { if ("$1-5".equals(range) && price.compareTo(BigDecimal.ONE) >= 0 && price.compareTo(BigDecimal.valueOf(5)) <= 0) return true; if ("$5-10".equals(range) && price.compareTo(BigDecimal.valueOf(5)) > 0 && price.compareTo(BigDecimal.TEN) <= 0) return true; if ("$10-20".equals(range) && price.compareTo(BigDecimal.TEN) > 0 && price.compareTo(BigDecimal.valueOf(20)) <= 0) return true; if ("$20+".equals(range) && price.compareTo(BigDecimal.valueOf(20)) > 0) return true; } return false; } private boolean containsIgnoreCase(String text, String value) { return text != null && value != null && text.toLowerCase(java.util.Locale.ROOT).contains(value.toLowerCase(java.util.Locale.ROOT)); } private boolean matchesTextFilter(ProductResponse product, String value, String alias) { com.singsong.dto.TagsDto tags = product.getTags(); return containsIgnoreCase(product.getNameCn(), value) || containsIgnoreCase(product.getNameEn(), value) || containsIgnoreCase(product.getDescription(), value) || containsIgnoreCase(product.getCategory(), value) || containsIgnoreCase(tags.getSeason(), value) || containsIgnoreCase(tags.getFunction(), value) || tags.getVisual().stream().anyMatch(item -> containsIgnoreCase(item, value)) || alias != null && (containsIgnoreCase(product.getNameEn(), alias) || containsIgnoreCase(product.getDescription(), alias) || containsIgnoreCase(product.getCategory(), alias) || containsIgnoreCase(tags.getSeason(), alias) || containsIgnoreCase(tags.getFunction(), alias) || tags.getVisual().stream().anyMatch(item -> containsIgnoreCase(item, alias))); } private String seasonAlias(String value) { switch (value) { case "春季": return "spring"; case "夏季": return "summer"; case "秋季": return "fall"; case "冬季": return "winter"; case "万圣节": return "halloween"; case "圣诞节": return "christmas"; default: return null; } } private String visualAlias(String value) { switch (value) { case "南瓜": return "pumpkin"; case "向日葵": return "sunflower"; case "枫叶": return "maple"; case "松鼠": return "squirrel"; case "卡车": return "truck"; default: return null; } } private String functionAlias(String value) { switch (value) { case "插枝": return "stake"; case "串绳": return "garland"; case "墙挂": return "wall"; case "桌上摆件": return "tabletop"; default: return null; } } private boolean inYearRange(ProductResponse product, List values) { return product.getListedYear() != null && values.contains(product.getListedYear().toString()); } private void sort(List products, String sort) { products.sort(comparator(sort, true)); } private Comparator comparator(String sort, boolean hasQuery) { if ("价格从低到高".equals(sort)) { return Comparator.comparing(item -> item.getProduct().getPrice(), Comparator.nullsLast(Comparator.naturalOrder())); } if ("价格从高到低".equals(sort)) { return Comparator.comparing(item -> item.getProduct().getPrice(), Comparator.nullsLast(Comparator.reverseOrder())); } if ("最新上架".equals(sort)) { return Comparator.comparing(item -> remoteId(item.getProduct().getId()), Comparator.nullsLast(Comparator.reverseOrder())); } return hasQuery ? Comparator.comparingInt(ScoredProduct::getRelevance).reversed() .thenComparing(Comparator.comparingInt(ScoredProduct::getFirstMatchedTermIndex)) .thenComparing(Comparator.comparingInt(ScoredProduct::getTermOrder).reversed()) : Comparator.comparing(item -> remoteId(item.getProduct().getId()), Comparator.nullsLast(Comparator.naturalOrder())); } private Long remoteId(String id) { if (!isRemoteId(id)) return null; try { return Long.valueOf(id.substring(Customer144ProductMapper.ID_PREFIX.length())); } catch (NumberFormatException exception) { return null; } } 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(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 orderedIds; private final Map> filterOptions; private final long expiresAt; private SearchCacheEntry(List orderedIds, Map> filterOptions, long expiresAt) { this.orderedIds = Collections.unmodifiableList(new ArrayList<>(orderedIds)); Map> 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 getOrderedIds() { return orderedIds; } private Map> getFilterOptions() { return filterOptions; } private long getExpiresAt() { return expiresAt; } } }