| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841 |
- 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<String, String> 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<String, QueryTranslationCacheEntry> queryTranslationCache = Collections.synchronizedMap(
- new LinkedHashMap<String, QueryTranslationCacheEntry>(128, 0.75f, true) {
- @Override
- protected boolean removeEldestEntry(Map.Entry<String, QueryTranslationCacheEntry> eldest) {
- 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,
- 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<PaginatedResponse<ProductResponse>> search(ProductSearchRequest request) {
- if (!properties.isConfigured()) {
- return Optional.empty();
- }
- List<ProductNameSearch.ParsedQuery> 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<ProductResponse> 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<ProductResponse> scanFiltered(ProductSearchRequest request,
- List<ProductNameSearch.ParsedQuery> 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<ProductResponse> browseItems = browsePage.getItems().stream()
- .map(mapper::toProduct).collect(Collectors.toList());
- return PaginatedResponse.<ProductResponse>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<ScoredProduct> bestFirst = comparator(request.getSort(), hasQuery)
- .thenComparing(item -> item.getProduct().getId());
- 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)) {
- 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<ProductResponse> browseItems = browsePage.getItems().stream()
- .map(mapper::toProduct).collect(Collectors.toList());
- return PaginatedResponse.<ProductResponse>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<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(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()
- .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<ProductResponse> findById(String id) {
- Long remoteId = remoteId(id);
- return remoteId == null ? Optional.empty() : snapshotStore.findById(remoteId).map(mapper::toProduct);
- }
- public List<ProductResponse> findSimilar(String id, int limit) {
- return findSimilar(id, limit, null);
- }
- public List<ProductResponse> 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<ProductResponse> candidates = search(request)
- .map(PaginatedResponse::getItems).orElseGet(Collections::emptyList)
- .stream().filter(product -> !id.equals(product.getId()))
- .collect(Collectors.toList());
- return randomSample(candidates, resultLimit);
- }
- List<ProductResponse> 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<ProductResponse> randomSample(List<ProductResponse> candidates, int resultLimit) {
- List<ProductResponse> 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<Long> seenRemoteIds = new HashSet<>();
- Set<String> brands = new HashSet<>();
- Set<String> 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<Customer144Product> 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<ProductNameSearch.ParsedQuery> queryVariants(String rawQuery) {
- ProductNameSearch.ParsedQuery original = ProductNameSearch.parse(rawQuery);
- List<ProductNameSearch.ParsedQuery> 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<String, String> localQueryTranslations() {
- Map<String, String> 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<String> translatedTerms = new LinkedHashSet<>();
- for (int index = 0; index < query.length();) {
- Map.Entry<String, String> best = null;
- for (Map.Entry<String, String> 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<Customer144Product> candidates(List<ProductNameSearch.ParsedQuery> parsedQueries,
- boolean fuzzyLookup) {
- Map<Long, Customer144Product> merged = new LinkedHashMap<>();
- for (ProductNameSearch.ParsedQuery query : parsedQueries) {
- List<List<String>> 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<ProductNameSearch.ParsedQuery> 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<ProductNameSearch.ParsedQuery> 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<ProductNameSearch.ParsedQuery> 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<ProductNameSearch.ParsedQuery> 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<List<String>> candidateTermGroups(ProductNameSearch.ParsedQuery parsedQuery,
- Map<String, List<String>> filters,
- boolean fuzzyLookup) {
- List<List<String>> groups = new ArrayList<>();
- if (!parsedQuery.getTerms().isEmpty()) {
- List<String> lookupTerms = fuzzyLookup ? parsedQuery.getLookupTerms() : parsedQuery.getTerms();
- groups.add(new ArrayList<>(new LinkedHashSet<>(lookupTerms)));
- }
- if (filters == null) return groups;
- for (Map.Entry<String, List<String>> entry : filters.entrySet()) {
- if (entry.getValue() == null || entry.getValue().isEmpty() || "price".equals(entry.getKey())) continue;
- LinkedHashSet<String> 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<String> 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<String, List<String>> 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<String, List<String>> filters) {
- if (filters == null || filters.isEmpty()) return true;
- for (Map.Entry<String, List<String>> entry : filters.entrySet()) {
- List<String> 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<String> 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<String> values) {
- return product.getListedYear() != null && values.contains(product.getListedYear().toString());
- }
- private void sort(List<ScoredProduct> products, String sort) {
- products.sort(comparator(sort, true));
- }
- private Comparator<ScoredProduct> 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<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; }
- }
- }
|