Customer144CatalogService.java 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  1. package com.singsong.customer144;
  2. import com.singsong.config.AsyncConfig;
  3. import com.singsong.dto.PaginatedResponse;
  4. import com.singsong.dto.ProductResponse;
  5. import com.singsong.dto.ProductSearchRequest;
  6. import com.singsong.search.ProductNameSearch;
  7. import com.singsong.service.ProductService;
  8. import com.singsong.service.SimilarityService;
  9. import javax.annotation.PostConstruct;
  10. import lombok.extern.slf4j.Slf4j;
  11. import org.springframework.beans.factory.annotation.Qualifier;
  12. import org.springframework.scheduling.annotation.Scheduled;
  13. import org.springframework.stereotype.Service;
  14. import java.math.BigDecimal;
  15. import java.time.Instant;
  16. import java.time.LocalDateTime;
  17. import java.util.AbstractMap;
  18. import java.util.ArrayList;
  19. import java.util.Collections;
  20. import java.util.Comparator;
  21. import java.util.HashSet;
  22. import java.util.LinkedHashMap;
  23. import java.util.LinkedHashSet;
  24. import java.util.List;
  25. import java.util.Map;
  26. import java.util.Optional;
  27. import java.util.Set;
  28. import java.util.TreeSet;
  29. import java.util.concurrent.Executor;
  30. import java.util.concurrent.atomic.AtomicBoolean;
  31. import java.util.regex.Matcher;
  32. import java.util.regex.Pattern;
  33. import java.util.stream.Collectors;
  34. @Slf4j
  35. @Service
  36. public class Customer144CatalogService {
  37. private static final Pattern HAN_PATTERN = Pattern.compile("[\\p{IsHan}]");
  38. private static final int QUERY_TRANSLATION_CACHE_SIZE = 500;
  39. private static final long QUERY_TRANSLATION_FAILURE_CACHE_MILLIS = 5 * 60 * 1000L;
  40. private static final Map<String, String> LOCAL_QUERY_TRANSLATIONS = localQueryTranslations();
  41. private final Customer144Properties properties;
  42. private final Customer144Client client;
  43. private final Customer144ProductMapper mapper;
  44. private final Customer144SnapshotStore snapshotStore;
  45. private final SimilarityService similarityService;
  46. private final Customer144TranslationService translationService;
  47. private final Executor syncExecutor;
  48. private final AtomicBoolean syncing = new AtomicBoolean(false);
  49. private final Map<String, QueryTranslationCacheEntry> queryTranslationCache = Collections.synchronizedMap(
  50. new LinkedHashMap<String, QueryTranslationCacheEntry>(128, 0.75f, true) {
  51. @Override
  52. protected boolean removeEldestEntry(Map.Entry<String, QueryTranslationCacheEntry> eldest) {
  53. return size() > QUERY_TRANSLATION_CACHE_SIZE;
  54. }
  55. });
  56. private final Map<String, SearchCacheEntry> searchResultCache = new LinkedHashMap<>(16, 0.75f, true);
  57. private volatile Instant lastFullSync;
  58. public Customer144CatalogService(Customer144Properties properties, Customer144Client client,
  59. Customer144ProductMapper mapper, Customer144SnapshotStore snapshotStore,
  60. SimilarityService similarityService,
  61. Customer144TranslationService translationService,
  62. @Qualifier(AsyncConfig.CUSTOMER144_SYNC_EXECUTOR) Executor syncExecutor) {
  63. this.properties = properties;
  64. this.client = client;
  65. this.mapper = mapper;
  66. this.snapshotStore = snapshotStore;
  67. this.similarityService = similarityService;
  68. this.translationService = translationService;
  69. this.syncExecutor = syncExecutor;
  70. }
  71. @PostConstruct
  72. void initialize() {
  73. if (!properties.isConfigured()) return;
  74. long snapshotCount = snapshotStore.count();
  75. log.info("已加载客户144商品数据库快照,共 {} 条", snapshotCount);
  76. snapshotStore.stats();
  77. if (snapshotCount > 0) {
  78. startSync(LocalDateTime.now().toLocalDate().atStartOfDay(), false);
  79. } else {
  80. startSync(null, true);
  81. }
  82. }
  83. @Scheduled(fixedDelayString = "#{@customer144Properties.refreshInterval.toMillis()}")
  84. public void scheduledSync() {
  85. startSync(LocalDateTime.now().toLocalDate().atStartOfDay(), false);
  86. }
  87. public Customer144SnapshotStore.SnapshotStats stats() {
  88. return snapshotStore.stats();
  89. }
  90. public Optional<PaginatedResponse<ProductResponse>> search(ProductSearchRequest request) {
  91. if (!properties.isConfigured()) {
  92. return Optional.empty();
  93. }
  94. List<ProductNameSearch.ParsedQuery> parsedQueries = queryVariants(request.getQuery());
  95. int page = request.getPage() == null || request.getPage() < 1 ? 1 : request.getPage();
  96. int pageSize = request.getPageSize() == null || request.getPageSize() < 1
  97. ? 8 : Math.min(request.getPageSize(), 100);
  98. PaginatedResponse<ProductResponse> result = scanFiltered(request, parsedQueries, page, pageSize, false);
  99. if (result.getTotal() == 0 && hasFuzzyLookup(parsedQueries)) {
  100. result = scanFiltered(request, parsedQueries, page, pageSize, true);
  101. }
  102. return Optional.of(result);
  103. }
  104. private PaginatedResponse<ProductResponse> scanFiltered(ProductSearchRequest request,
  105. List<ProductNameSearch.ParsedQuery> parsedQueries,
  106. int page, int pageSize,
  107. boolean fuzzyLookup) {
  108. boolean hasQuery = parsedQueries.stream().anyMatch(query -> !query.getTerms().isEmpty());
  109. if (!hasQuery && !hasActiveFilters(request.getFilters()) && !hasNonDefaultSort(request.getSort())) {
  110. Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize);
  111. List<ProductResponse> browseItems = browsePage.getItems().stream()
  112. .map(mapper::toProduct).collect(Collectors.toList());
  113. return PaginatedResponse.<ProductResponse>builder()
  114. .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
  115. .filterOptions(ProductService.filterOptions(browseItems)).build();
  116. }
  117. String cacheKey = searchCacheKey(request, parsedQueries, fuzzyLookup);
  118. SearchCacheEntry cached = cachedSearch(cacheKey);
  119. if (cached != null) return cachedPage(cached, page, pageSize);
  120. Comparator<ScoredProduct> bestFirst = comparator(request.getSort(), hasQuery)
  121. .thenComparing(item -> item.getProduct().getId());
  122. List<ScoredProduct> matches = new ArrayList<>();
  123. Set<String> visualOptions = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
  124. Set<String> yearOptions = new TreeSet<>(Comparator.reverseOrder());
  125. for (Customer144Product source : candidates(parsedQueries, fuzzyLookup)) {
  126. ProductResponse product = mapper.toProduct(source);
  127. int score = hasQuery ? relevance(parsedQueries, product) : 0;
  128. int matchedIndex = hasQuery ? firstMatchedTermIndex(parsedQueries, product) : 0;
  129. int orderScore = hasQuery ? termOrder(parsedQueries, product) : 0;
  130. if (hasQuery && score <= 0) continue;
  131. collectFilterOptions(product, visualOptions, yearOptions);
  132. if (!matchesFilters(product, request.getFilters())) continue;
  133. matches.add(new ScoredProduct(source.getId(), product, score, matchedIndex, orderScore));
  134. }
  135. if (!hasQuery && matches.isEmpty() && !hasActiveFilters(request.getFilters())) {
  136. Customer144SnapshotStore.SearchBatch browsePage = snapshotStore.browse(page, pageSize);
  137. List<ProductResponse> browseItems = browsePage.getItems().stream()
  138. .map(mapper::toProduct).collect(Collectors.toList());
  139. return PaginatedResponse.<ProductResponse>builder()
  140. .items(browseItems).total(browsePage.getTotal()).page(page).pageSize(pageSize)
  141. .filterOptions(ProductService.filterOptions(browseItems)).build();
  142. }
  143. matches.sort(bestFirst);
  144. int from = pageOffset(page, pageSize, matches.size());
  145. int to = Math.min(from + pageSize, matches.size());
  146. List<ProductResponse> items = matches.subList(from, to).stream()
  147. .map(ScoredProduct::getProduct).collect(Collectors.toList());
  148. Map<String, List<String>> filterOptions = new LinkedHashMap<>();
  149. filterOptions.put("visual", new ArrayList<>(visualOptions));
  150. filterOptions.put("year", new ArrayList<>(yearOptions));
  151. List<Long> orderedIds = matches.stream().map(ScoredProduct::getRemoteId).collect(Collectors.toList());
  152. cacheSearch(cacheKey, orderedIds, filterOptions);
  153. return PaginatedResponse.<ProductResponse>builder()
  154. .items(items).total(matches.size()).page(page).pageSize(pageSize)
  155. .filterOptions(filterOptions).build();
  156. }
  157. private PaginatedResponse<ProductResponse> cachedPage(SearchCacheEntry cached, int page, int pageSize) {
  158. int from = pageOffset(page, pageSize, cached.getOrderedIds().size());
  159. int to = Math.min(from + pageSize, cached.getOrderedIds().size());
  160. List<Long> pageIds = cached.getOrderedIds().subList(from, to);
  161. List<ProductResponse> items = snapshotStore.findAllByIds(pageIds).stream()
  162. .map(mapper::toProduct).collect(Collectors.toList());
  163. return PaginatedResponse.<ProductResponse>builder()
  164. .items(items).total(cached.getOrderedIds().size()).page(page).pageSize(pageSize)
  165. .filterOptions(cached.getFilterOptions()).build();
  166. }
  167. private int pageOffset(int page, int pageSize, int total) {
  168. long offset = (long) Math.max(page - 1, 0) * pageSize;
  169. return (int) Math.min(offset, total);
  170. }
  171. private String searchCacheKey(ProductSearchRequest request,
  172. List<ProductNameSearch.ParsedQuery> parsedQueries,
  173. boolean fuzzyLookup) {
  174. StringBuilder key = new StringBuilder(fuzzyLookup ? "fuzzy" : "exact");
  175. for (ProductNameSearch.ParsedQuery query : parsedQueries) {
  176. appendCachePart(key, query.getNormalized());
  177. for (String term : query.getTerms()) appendCachePart(key, term);
  178. if (fuzzyLookup) {
  179. for (String term : query.getLookupTerms()) appendCachePart(key, term);
  180. }
  181. }
  182. appendCachePart(key, request.getSort());
  183. Map<String, List<String>> filters = request.getFilters();
  184. if (filters != null) {
  185. List<String> filterNames = new ArrayList<>(filters.keySet());
  186. Collections.sort(filterNames);
  187. for (String filterName : filterNames) {
  188. appendCachePart(key, filterName);
  189. List<String> values = filters.get(filterName);
  190. if (values == null) continue;
  191. List<String> sortedValues = new ArrayList<>(values);
  192. sortedValues.sort(String.CASE_INSENSITIVE_ORDER);
  193. for (String value : sortedValues) appendCachePart(key, value);
  194. }
  195. }
  196. return key.toString();
  197. }
  198. private void appendCachePart(StringBuilder key, String value) {
  199. String safe = value == null ? "" : value.trim();
  200. key.append('|').append(safe.length()).append(':').append(safe);
  201. }
  202. private SearchCacheEntry cachedSearch(String key) {
  203. long now = System.currentTimeMillis();
  204. synchronized (searchResultCache) {
  205. SearchCacheEntry cached = searchResultCache.get(key);
  206. if (cached == null) return null;
  207. if (cached.getExpiresAt() > now) return cached;
  208. searchResultCache.remove(key);
  209. return null;
  210. }
  211. }
  212. private void cacheSearch(String key, List<Long> orderedIds,
  213. Map<String, List<String>> filterOptions) {
  214. int maxEntries = Math.max(properties.getSearchCacheSize(), 0);
  215. long ttlMillis = properties.getSearchCacheTtl() == null
  216. ? 0L : Math.max(properties.getSearchCacheTtl().toMillis(), 0L);
  217. if (maxEntries == 0 || ttlMillis == 0L) return;
  218. SearchCacheEntry entry = new SearchCacheEntry(orderedIds, filterOptions,
  219. System.currentTimeMillis() + ttlMillis);
  220. synchronized (searchResultCache) {
  221. searchResultCache.put(key, entry);
  222. while (searchResultCache.size() > maxEntries) {
  223. String eldest = searchResultCache.keySet().iterator().next();
  224. searchResultCache.remove(eldest);
  225. }
  226. }
  227. }
  228. private void clearSearchCache() {
  229. synchronized (searchResultCache) {
  230. searchResultCache.clear();
  231. }
  232. }
  233. private void collectFilterOptions(ProductResponse product, Set<String> visuals, Set<String> years) {
  234. if (product.getTags() != null && product.getTags().getVisual() != null) {
  235. product.getTags().getVisual().stream()
  236. .filter(value -> value != null && !value.trim().isEmpty() && !"-".equals(value.trim()))
  237. .map(String::trim).forEach(visuals::add);
  238. }
  239. if (product.getListedYear() != null) years.add(product.getListedYear().toString());
  240. }
  241. public Optional<ProductResponse> findById(String id) {
  242. Long remoteId = remoteId(id);
  243. return remoteId == null ? Optional.empty() : snapshotStore.findById(remoteId).map(mapper::toProduct);
  244. }
  245. public List<ProductResponse> findSimilar(String id, int limit) {
  246. return findSimilar(id, limit, null);
  247. }
  248. public List<ProductResponse> findSimilar(String id, int limit, String query) {
  249. Long remoteId = remoteId(id);
  250. ProductResponse target = findById(id).orElse(null);
  251. if (target == null || remoteId == null) return Collections.emptyList();
  252. int resultLimit = limit <= 0 ? 5 : limit;
  253. if (hasText(query)) {
  254. if (ProductNameSearch.parse(query).getTerms().isEmpty()) return Collections.emptyList();
  255. ProductSearchRequest request = new ProductSearchRequest();
  256. request.setQuery(query.trim());
  257. request.setPage(1);
  258. request.setPageSize(recommendationPoolSize(resultLimit));
  259. List<ProductResponse> candidates = search(request)
  260. .map(PaginatedResponse::getItems).orElseGet(Collections::emptyList)
  261. .stream().filter(product -> !id.equals(product.getId()))
  262. .collect(Collectors.toList());
  263. return randomSample(candidates, resultLimit);
  264. }
  265. List<ProductResponse> candidates = snapshotStore.similarPool().stream()
  266. .filter(product -> !remoteId.equals(product.getId()))
  267. .map(mapper::toProduct)
  268. .map(product -> new AbstractMap.SimpleEntry<>(product, similarityService.score(target, product)))
  269. .sorted((left, right) -> Integer.compare(right.getValue(), left.getValue()))
  270. .filter(entry -> entry.getValue() > 0)
  271. .limit(recommendationPoolSize(resultLimit))
  272. .map(Map.Entry::getKey).collect(Collectors.toList());
  273. return randomSample(candidates, resultLimit);
  274. }
  275. private int recommendationPoolSize(int resultLimit) {
  276. return (int) Math.min(Math.max((long) resultLimit * 10, 50L), 100L);
  277. }
  278. private List<ProductResponse> randomSample(List<ProductResponse> candidates, int resultLimit) {
  279. List<ProductResponse> shuffled = new ArrayList<>(candidates);
  280. Collections.shuffle(shuffled);
  281. return shuffled.stream().limit(resultLimit).collect(Collectors.toList());
  282. }
  283. public boolean isRemoteId(String id) {
  284. return id != null && id.startsWith(Customer144ProductMapper.ID_PREFIX);
  285. }
  286. void startFullSync() {
  287. startSync(null, true);
  288. }
  289. private void startSync(LocalDateTime updatedAfter, boolean replaceAll) {
  290. if (!properties.isConfigured() || !syncing.compareAndSet(false, true)) return;
  291. try {
  292. syncExecutor.execute(() -> {
  293. try {
  294. syncAllPages(updatedAfter, replaceAll);
  295. } finally {
  296. syncing.set(false);
  297. }
  298. });
  299. } catch (RuntimeException exception) {
  300. syncing.set(false);
  301. log.warn("客户144商品快照同步任务提交失败: {}", exception.getMessage());
  302. }
  303. }
  304. void syncAllPages() {
  305. syncAllPages(null, true);
  306. }
  307. void syncUpdatedToday() {
  308. syncAllPages(LocalDateTime.now().toLocalDate().atStartOfDay(), false);
  309. }
  310. private void syncAllPages(LocalDateTime updatedAfter, boolean replaceAll) {
  311. int limit = Math.min(Math.max(properties.getPageSize(), 1), 500);
  312. int offset = 0;
  313. int pageNumber = 0;
  314. long persisted = 0L;
  315. LocalDateTime syncedAt = LocalDateTime.now();
  316. Set<Long> seenRemoteIds = new HashSet<>();
  317. Set<String> brands = new HashSet<>();
  318. Set<String> productTypes = new HashSet<>();
  319. try {
  320. while (true) {
  321. Customer144Page page = client.fetchPage(limit, offset, updatedAfter);
  322. if (page == null || page.getData() == null || page.getCount() == null) {
  323. throw new IllegalStateException("客户144接口响应不完整");
  324. }
  325. List<Customer144Product> data = page.getData();
  326. if (page.getCount() != data.size()) {
  327. throw new IllegalStateException("客户144接口count按本页条数校验时与data数量不一致");
  328. }
  329. if (page.getOffset() != null && page.getOffset() != offset) {
  330. throw new IllegalStateException("客户144接口offset不连续");
  331. }
  332. if (page.getLimit() != null && page.getLimit() != limit) {
  333. throw new IllegalStateException("客户144接口limit不一致");
  334. }
  335. for (Customer144Product product : data) {
  336. if (product == null || product.getId() == null) throw new IllegalStateException("客户144商品ID为空");
  337. if (!seenRemoteIds.add(product.getId())) {
  338. throw new IllegalStateException("客户144商品ID重复: " + product.getId());
  339. }
  340. }
  341. if (replaceAll) {
  342. snapshotStore.upsertSnapshotPage(data, syncedAt);
  343. for (Customer144Product product : data) {
  344. if (hasText(product.getBrand())) brands.add(product.getBrand().trim());
  345. if (hasText(product.getProductType())) {
  346. productTypes.add(product.getProductType().trim());
  347. }
  348. }
  349. } else {
  350. snapshotStore.mergeSnapshot(data, syncedAt);
  351. }
  352. persisted += data.size();
  353. pageNumber++;
  354. if (pageNumber == 1 || pageNumber % 20 == 0 || data.size() < limit) {
  355. log.info("客户144商品{}同步分页落库,页数={},本轮已落库={} 条",
  356. replaceAll ? "全量" : "增量", pageNumber, persisted);
  357. }
  358. if (data.size() < limit) break;
  359. offset += data.size();
  360. try {
  361. // 页间稍作停顿,避免高频请求触发接口方限流(503 SERVICE_BUSY)
  362. Thread.sleep(200L);
  363. } catch (InterruptedException interrupted) {
  364. Thread.currentThread().interrupt();
  365. throw new IllegalStateException("客户144商品同步已中断", interrupted);
  366. }
  367. }
  368. if (replaceAll) {
  369. int removed = snapshotStore.completeFullSync(syncedAt, persisted,
  370. brands.size(), productTypes.size());
  371. log.info("客户144商品全量同步完成,共 {} 条,清理失效旧商品 {} 条", persisted, removed);
  372. } else {
  373. log.info("客户144商品今日增量同步完成,updated_after={},新增或更新 {} 条",
  374. updatedAfter, persisted);
  375. }
  376. clearSearchCache();
  377. lastFullSync = Instant.now();
  378. } catch (Exception exception) {
  379. log.warn("客户144商品快照同步失败,本轮已分页落库 {} 条,未执行旧商品清理: {}",
  380. persisted, exception.getMessage());
  381. }
  382. }
  383. private List<ProductNameSearch.ParsedQuery> queryVariants(String rawQuery) {
  384. ProductNameSearch.ParsedQuery original = ProductNameSearch.parse(rawQuery);
  385. List<ProductNameSearch.ParsedQuery> variants = new ArrayList<>();
  386. variants.add(original);
  387. if (!original.getNormalized().isEmpty() && HAN_PATTERN.matcher(original.getNormalized()).find()) {
  388. String translated = translateQueryToEnglish(original.getNormalized());
  389. if (hasText(translated)) {
  390. ProductNameSearch.ParsedQuery english = ProductNameSearch.parse(translated);
  391. if (!english.getTerms().isEmpty() && !english.getNormalized().equals(original.getNormalized())) {
  392. variants.add(english);
  393. }
  394. }
  395. }
  396. return variants;
  397. }
  398. private String translateQueryToEnglish(String query) {
  399. long now = System.currentTimeMillis();
  400. synchronized (queryTranslationCache) {
  401. QueryTranslationCacheEntry cached = queryTranslationCache.get(query);
  402. if (cached != null && !cached.isExpired(now)) return cached.getTranslation();
  403. if (cached != null) queryTranslationCache.remove(query);
  404. }
  405. String localTranslation = localQueryTranslation(query);
  406. if (hasText(localTranslation)) {
  407. cacheQueryTranslation(query, localTranslation, Long.MAX_VALUE);
  408. return localTranslation;
  409. }
  410. String translated = translationService.translateToEnglishOrNull(query);
  411. if (!hasText(translated)) {
  412. cacheQueryTranslation(query, null, now + QUERY_TRANSLATION_FAILURE_CACHE_MILLIS);
  413. return null;
  414. }
  415. String normalized = translated.trim();
  416. cacheQueryTranslation(query, normalized, Long.MAX_VALUE);
  417. return normalized;
  418. }
  419. private void cacheQueryTranslation(String query, String translation, long expiresAt) {
  420. synchronized (queryTranslationCache) {
  421. queryTranslationCache.put(query, new QueryTranslationCacheEntry(translation, expiresAt));
  422. }
  423. }
  424. private static Map<String, String> localQueryTranslations() {
  425. Map<String, String> translations = new LinkedHashMap<>();
  426. translations.put("礼品盒子", "gift box");
  427. translations.put("礼品盒", "gift box");
  428. translations.put("礼品袋", "gift bag");
  429. translations.put("圣诞老人", "santa");
  430. translations.put("圣诞树", "christmas tree");
  431. translations.put("桌上摆件", "tabletop decor");
  432. translations.put("墙上装饰", "wall decor");
  433. translations.put("墙面装饰", "wall decor");
  434. translations.put("户外装饰", "outdoor decor");
  435. translations.put("室内装饰", "indoor decor");
  436. translations.put("感恩节", "thanksgiving");
  437. translations.put("情人节", "valentine");
  438. translations.put("母亲节", "mother day");
  439. translations.put("万圣节", "halloween");
  440. translations.put("复活节", "easter");
  441. translations.put("圣诞节", "christmas");
  442. translations.put("生日礼物", "birthday gift");
  443. translations.put("礼盒", "gift box");
  444. translations.put("礼品", "gift");
  445. translations.put("礼物", "gift");
  446. translations.put("盒子", "box");
  447. translations.put("箱子", "box");
  448. translations.put("袋子", "bag");
  449. translations.put("篮子", "basket");
  450. translations.put("收纳", "storage");
  451. translations.put("装饰品", "decor");
  452. translations.put("装饰", "decor");
  453. translations.put("摆件", "figurine");
  454. translations.put("挂件", "ornament");
  455. translations.put("花环", "wreath");
  456. translations.put("花瓶", "vase");
  457. translations.put("花", "flower");
  458. translations.put("茶壶", "teapot");
  459. translations.put("茶杯", "teacup");
  460. translations.put("茶", "tea");
  461. translations.put("水晶", "crystal");
  462. translations.put("水", "water");
  463. translations.put("托盘", "tray");
  464. translations.put("烛台", "candle holder");
  465. translations.put("蜡烛", "candle");
  466. translations.put("灯笼", "lantern");
  467. translations.put("相框", "photo frame");
  468. translations.put("杯子", "mug");
  469. translations.put("枕头", "pillow");
  470. translations.put("毯子", "blanket");
  471. translations.put("头戴式耳机", "headphones");
  472. translations.put("耳机", "headphones");
  473. translations.put("耳麦", "headset");
  474. translations.put("圣诞", "christmas");
  475. translations.put("生日", "birthday");
  476. translations.put("婚礼", "wedding");
  477. translations.put("秋季", "fall");
  478. translations.put("春季", "spring");
  479. translations.put("夏季", "summer");
  480. translations.put("冬季", "winter");
  481. translations.put("南瓜", "pumpkin");
  482. translations.put("雪人", "snowman");
  483. translations.put("驯鹿", "reindeer");
  484. translations.put("麋鹿", "deer");
  485. translations.put("兔子", "bunny");
  486. translations.put("松鼠", "squirrel");
  487. translations.put("卡车", "truck");
  488. translations.put("向日葵", "sunflower");
  489. translations.put("枫叶", "maple leaf");
  490. translations.put("星星", "star");
  491. translations.put("爱心", "heart");
  492. translations.put("雪花", "snowflake");
  493. translations.put("红色", "red");
  494. translations.put("白色", "white");
  495. translations.put("黑色", "black");
  496. translations.put("绿色", "green");
  497. translations.put("蓝色", "blue");
  498. translations.put("粉色", "pink");
  499. translations.put("紫色", "purple");
  500. translations.put("橙色", "orange");
  501. translations.put("金色", "gold");
  502. translations.put("银色", "silver");
  503. translations.put("彩色", "multicolor");
  504. translations.put("金属", "metal");
  505. translations.put("木制", "wood");
  506. translations.put("木质", "wood");
  507. translations.put("玻璃", "glass");
  508. translations.put("陶瓷", "ceramic");
  509. translations.put("布艺", "fabric");
  510. translations.put("塑料", "plastic");
  511. return Collections.unmodifiableMap(translations);
  512. }
  513. private String localQueryTranslation(String query) {
  514. LinkedHashSet<String> translatedTerms = new LinkedHashSet<>();
  515. for (int index = 0; index < query.length();) {
  516. Map.Entry<String, String> best = null;
  517. for (Map.Entry<String, String> entry : LOCAL_QUERY_TRANSLATIONS.entrySet()) {
  518. if (!query.startsWith(entry.getKey(), index)) continue;
  519. if (best == null || entry.getKey().length() > best.getKey().length()) best = entry;
  520. }
  521. if (best == null) {
  522. index++;
  523. continue;
  524. }
  525. Collections.addAll(translatedTerms, best.getValue().split("\\s+"));
  526. index += best.getKey().length();
  527. }
  528. return translatedTerms.isEmpty() ? null : String.join(" ", translatedTerms);
  529. }
  530. private List<Customer144Product> candidates(List<ProductNameSearch.ParsedQuery> parsedQueries,
  531. boolean fuzzyLookup) {
  532. Map<Long, Customer144Product> merged = new LinkedHashMap<>();
  533. for (ProductNameSearch.ParsedQuery query : parsedQueries) {
  534. List<List<String>> groups = candidateTermGroups(query, Collections.emptyMap(), fuzzyLookup);
  535. for (Customer144Product product : snapshotStore.candidates(groups)) {
  536. if (product != null && product.getId() != null) {
  537. merged.putIfAbsent(product.getId(), product);
  538. }
  539. }
  540. }
  541. return new ArrayList<>(merged.values());
  542. }
  543. private int relevance(List<ProductNameSearch.ParsedQuery> queries, ProductResponse product) {
  544. int best = 0;
  545. for (ProductNameSearch.ParsedQuery query : queries) {
  546. best = Math.max(best, ProductNameSearch.relevance(
  547. query, product.getNameCn(), product.getNameEn()));
  548. }
  549. return best;
  550. }
  551. private int firstMatchedTermIndex(List<ProductNameSearch.ParsedQuery> queries, ProductResponse product) {
  552. int best = Integer.MAX_VALUE;
  553. for (ProductNameSearch.ParsedQuery query : queries) {
  554. best = Math.min(best, ProductNameSearch.firstMatchedTermIndex(
  555. query, product.getNameCn(), product.getNameEn()));
  556. }
  557. return best;
  558. }
  559. private int termOrder(List<ProductNameSearch.ParsedQuery> queries, ProductResponse product) {
  560. int best = 0;
  561. for (ProductNameSearch.ParsedQuery query : queries) {
  562. best = Math.max(best, ProductNameSearch.termOrder(
  563. query, product.getNameCn(), product.getNameEn()));
  564. }
  565. return best;
  566. }
  567. private boolean hasFuzzyLookup(List<ProductNameSearch.ParsedQuery> parsedQueries) {
  568. return parsedQueries.stream().anyMatch(query ->
  569. !new LinkedHashSet<>(query.getTerms())
  570. .equals(new LinkedHashSet<>(query.getLookupTerms())));
  571. }
  572. private boolean hasText(String value) {
  573. return value != null && !value.trim().isEmpty();
  574. }
  575. private static final class QueryTranslationCacheEntry {
  576. private final String translation;
  577. private final long expiresAt;
  578. private QueryTranslationCacheEntry(String translation, long expiresAt) {
  579. this.translation = translation;
  580. this.expiresAt = expiresAt;
  581. }
  582. private String getTranslation() {
  583. return translation;
  584. }
  585. private boolean isExpired(long now) {
  586. return now >= expiresAt;
  587. }
  588. }
  589. private List<List<String>> candidateTermGroups(ProductNameSearch.ParsedQuery parsedQuery,
  590. Map<String, List<String>> filters,
  591. boolean fuzzyLookup) {
  592. List<List<String>> groups = new ArrayList<>();
  593. if (!parsedQuery.getTerms().isEmpty()) {
  594. List<String> lookupTerms = fuzzyLookup ? parsedQuery.getLookupTerms() : parsedQuery.getTerms();
  595. groups.add(new ArrayList<>(new LinkedHashSet<>(lookupTerms)));
  596. }
  597. if (filters == null) return groups;
  598. for (Map.Entry<String, List<String>> entry : filters.entrySet()) {
  599. if (entry.getValue() == null || entry.getValue().isEmpty() || "price".equals(entry.getKey())) continue;
  600. LinkedHashSet<String> terms = new LinkedHashSet<>();
  601. for (String value : entry.getValue()) {
  602. addCandidateTerm(terms, value);
  603. String alias = filterAlias(entry.getKey(), value);
  604. addCandidateTerm(terms, alias);
  605. }
  606. if (!terms.isEmpty()) groups.add(new ArrayList<>(terms));
  607. }
  608. return groups;
  609. }
  610. private void addCandidateTerm(LinkedHashSet<String> terms, String value) {
  611. if (value != null && !value.trim().isEmpty()) terms.add(value.trim());
  612. }
  613. private String filterAlias(String key, String value) {
  614. switch (key) {
  615. case "season": return seasonAlias(value);
  616. case "visual": return visualAlias(value);
  617. case "function": return functionAlias(value);
  618. default: return null;
  619. }
  620. }
  621. private boolean hasActiveFilters(Map<String, List<String>> filters) {
  622. return filters != null && filters.values().stream()
  623. .anyMatch(values -> values != null && !values.isEmpty());
  624. }
  625. private boolean hasNonDefaultSort(String sort) {
  626. return sort != null && !sort.trim().isEmpty() && !"相关度".equals(sort);
  627. }
  628. private boolean matchesFilters(ProductResponse product, Map<String, List<String>> filters) {
  629. if (filters == null || filters.isEmpty()) return true;
  630. for (Map.Entry<String, List<String>> entry : filters.entrySet()) {
  631. List<String> values = entry.getValue();
  632. if (values == null || values.isEmpty()) continue;
  633. boolean matches;
  634. switch (entry.getKey()) {
  635. case "series": matches = values.stream().anyMatch(value ->
  636. containsIgnoreCase(product.getTags().getSeries(), value)); break;
  637. case "season": matches = values.stream().anyMatch(value ->
  638. matchesTextFilter(product, value, seasonAlias(value))); break;
  639. case "visual": matches = values.stream().anyMatch(value ->
  640. matchesTextFilter(product, value, visualAlias(value))); break;
  641. case "function": matches = values.stream().anyMatch(value ->
  642. matchesTextFilter(product, value, functionAlias(value))); break;
  643. case "material": matches = values.stream().anyMatch(value ->
  644. containsIgnoreCase(product.getTags().getMaterial(), value)); break;
  645. case "price": matches = product.getPrice() != null && inPriceRange(product.getPrice(), values); break;
  646. case "year": matches = inYearRange(product, values); break;
  647. default: matches = true;
  648. }
  649. if (!matches) return false;
  650. }
  651. return true;
  652. }
  653. private boolean inPriceRange(BigDecimal price, List<String> values) {
  654. for (String range : values) {
  655. if ("$1-5".equals(range) && price.compareTo(BigDecimal.ONE) >= 0 && price.compareTo(BigDecimal.valueOf(5)) <= 0) return true;
  656. if ("$5-10".equals(range) && price.compareTo(BigDecimal.valueOf(5)) > 0 && price.compareTo(BigDecimal.TEN) <= 0) return true;
  657. if ("$10-20".equals(range) && price.compareTo(BigDecimal.TEN) > 0 && price.compareTo(BigDecimal.valueOf(20)) <= 0) return true;
  658. if ("$20+".equals(range) && price.compareTo(BigDecimal.valueOf(20)) > 0) return true;
  659. }
  660. return false;
  661. }
  662. private boolean containsIgnoreCase(String text, String value) {
  663. return text != null && value != null
  664. && text.toLowerCase(java.util.Locale.ROOT).contains(value.toLowerCase(java.util.Locale.ROOT));
  665. }
  666. private boolean matchesTextFilter(ProductResponse product, String value, String alias) {
  667. com.singsong.dto.TagsDto tags = product.getTags();
  668. return containsIgnoreCase(product.getNameCn(), value) || containsIgnoreCase(product.getNameEn(), value)
  669. || containsIgnoreCase(product.getDescription(), value) || containsIgnoreCase(product.getCategory(), value)
  670. || containsIgnoreCase(tags.getSeason(), value) || containsIgnoreCase(tags.getFunction(), value)
  671. || tags.getVisual().stream().anyMatch(item -> containsIgnoreCase(item, value))
  672. || alias != null && (containsIgnoreCase(product.getNameEn(), alias)
  673. || containsIgnoreCase(product.getDescription(), alias) || containsIgnoreCase(product.getCategory(), alias)
  674. || containsIgnoreCase(tags.getSeason(), alias) || containsIgnoreCase(tags.getFunction(), alias)
  675. || tags.getVisual().stream().anyMatch(item -> containsIgnoreCase(item, alias)));
  676. }
  677. private String seasonAlias(String value) {
  678. switch (value) {
  679. case "春季": return "spring";
  680. case "夏季": return "summer";
  681. case "秋季": return "fall";
  682. case "冬季": return "winter";
  683. case "万圣节": return "halloween";
  684. case "圣诞节": return "christmas";
  685. default: return null;
  686. }
  687. }
  688. private String visualAlias(String value) {
  689. switch (value) {
  690. case "南瓜": return "pumpkin";
  691. case "向日葵": return "sunflower";
  692. case "枫叶": return "maple";
  693. case "松鼠": return "squirrel";
  694. case "卡车": return "truck";
  695. default: return null;
  696. }
  697. }
  698. private String functionAlias(String value) {
  699. switch (value) {
  700. case "插枝": return "stake";
  701. case "串绳": return "garland";
  702. case "墙挂": return "wall";
  703. case "桌上摆件": return "tabletop";
  704. default: return null;
  705. }
  706. }
  707. private boolean inYearRange(ProductResponse product, List<String> values) {
  708. return product.getListedYear() != null && values.contains(product.getListedYear().toString());
  709. }
  710. private void sort(List<ScoredProduct> products, String sort) {
  711. products.sort(comparator(sort, true));
  712. }
  713. private Comparator<ScoredProduct> comparator(String sort, boolean hasQuery) {
  714. if ("价格从低到高".equals(sort)) {
  715. return Comparator.comparing(item -> item.getProduct().getPrice(),
  716. Comparator.nullsLast(Comparator.naturalOrder()));
  717. }
  718. if ("价格从高到低".equals(sort)) {
  719. return Comparator.comparing(item -> item.getProduct().getPrice(),
  720. Comparator.nullsLast(Comparator.reverseOrder()));
  721. }
  722. if ("最新上架".equals(sort)) {
  723. return Comparator.comparing(item -> remoteId(item.getProduct().getId()),
  724. Comparator.nullsLast(Comparator.reverseOrder()));
  725. }
  726. return hasQuery ? Comparator.comparingInt(ScoredProduct::getRelevance).reversed()
  727. .thenComparing(Comparator.comparingInt(ScoredProduct::getFirstMatchedTermIndex))
  728. .thenComparing(Comparator.comparingInt(ScoredProduct::getTermOrder).reversed())
  729. : Comparator.comparing(item -> remoteId(item.getProduct().getId()),
  730. Comparator.nullsLast(Comparator.naturalOrder()));
  731. }
  732. private Long remoteId(String id) {
  733. if (!isRemoteId(id)) return null;
  734. try { return Long.valueOf(id.substring(Customer144ProductMapper.ID_PREFIX.length())); }
  735. catch (NumberFormatException exception) { return null; }
  736. }
  737. private static final class ScoredProduct {
  738. private final Long remoteId;
  739. private final ProductResponse product;
  740. private final int relevance;
  741. private final int firstMatchedTermIndex;
  742. private final int termOrder;
  743. private ScoredProduct(Long remoteId, ProductResponse product, int relevance,
  744. int firstMatchedTermIndex, int termOrder) {
  745. this.remoteId = remoteId;
  746. this.product = product;
  747. this.relevance = relevance;
  748. this.firstMatchedTermIndex = firstMatchedTermIndex;
  749. this.termOrder = termOrder;
  750. }
  751. private Long getRemoteId() { return remoteId; }
  752. private ProductResponse getProduct() { return product; }
  753. private int getRelevance() { return relevance; }
  754. private int getFirstMatchedTermIndex() { return firstMatchedTermIndex; }
  755. private int getTermOrder() { return termOrder; }
  756. }
  757. private static final class SearchCacheEntry {
  758. private final List<Long> orderedIds;
  759. private final Map<String, List<String>> filterOptions;
  760. private final long expiresAt;
  761. private SearchCacheEntry(List<Long> orderedIds, Map<String, List<String>> filterOptions,
  762. long expiresAt) {
  763. this.orderedIds = Collections.unmodifiableList(new ArrayList<>(orderedIds));
  764. Map<String, List<String>> copiedOptions = new LinkedHashMap<>();
  765. filterOptions.forEach((name, values) -> copiedOptions.put(name,
  766. Collections.unmodifiableList(new ArrayList<>(values))));
  767. this.filterOptions = Collections.unmodifiableMap(copiedOptions);
  768. this.expiresAt = expiresAt;
  769. }
  770. private List<Long> getOrderedIds() { return orderedIds; }
  771. private Map<String, List<String>> getFilterOptions() { return filterOptions; }
  772. private long getExpiresAt() { return expiresAt; }
  773. }
  774. }