diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java index 17b5a7cc..c464d936 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java @@ -89,24 +89,12 @@ public class SmHotelAppController { private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW"; /** 列表页结果短缓存(同条件重复进入/下拉) */ - private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v8:"; + private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v9:"; private static final long PAGE_CACHE_SECONDS = 45L; - /** 先按区域/关键字缩小酒店集合时的上限,超出则回退 EXISTS */ - private static final int MAX_SCOPED_HOTEL_IDS = 3000; - - /** 产品最低卖价全量短缓存(排序/起价筛选,避免相关子查询扫产品表) */ - private static final String PMIN_CACHE_KEY = "sm:hotel:app:pmin:v1"; - - private static final long PMIN_CACHE_MINUTES = 5L; - - private static final String CAP_CACHE_PREFIX = "sm:hotel:app:cap:v1:"; - - private static final long CAP_CACHE_MINUTES = 5L; - - /** 超过该规模不再逐酒店 IN 聚合,改走全量最低价缓存 */ - private static final int MAX_PMIN_IN_HOTELS = 10000; + /** 城市/关键字等缩小后,在该规模内内存排序(超出则 SQL LIMIT,禁止全表 list) */ + private static final int MAX_SCOPED_HOTEL_IDS = 8000; private final SmHotelService hotelService; @@ -260,61 +248,80 @@ public class SmHotelAppController { return R.ok(cached); } - var idWrapper = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore, - facilityList); - idWrapper.select(SmHotelEntity::getId, SmHotelEntity::getStarLevel); - List matched = hotelService.list(idWrapper); - if (matched == null || matched.isEmpty()) { - Page> empty = emptyPage(page); - writePageCache(pageCacheKey, empty); - return R.ok(empty); - } - if (needCap > 1) { - matched = filterByGuestCapacity(matched, needCap); - if (matched.isEmpty()) { + boolean hasScopeFilter = districtAdcode != null || StrUtil.isNotBlank(district) || StrUtil.isNotBlank(city) + || StrUtil.isNotBlank(keyword) || StrUtil.isNotBlank(bizArea) || !starList.isEmpty() + || minScore != null || !facilityList.isEmpty(); + List scopedIds = null; + if (hasScopeFilter) { + scopedIds = loadScopedHotelIds(keyword, city, district, districtAdcode, bizArea, starList, minScore, + facilityList); + if (scopedIds != null && scopedIds.isEmpty()) { Page> empty = emptyPage(page); writePageCache(pageCacheKey, empty); return R.ok(empty); } } - List matchedIds = matched.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); - Map productMinMap = loadProductMinPrices(matchedIds); - if (minPrice != null || maxPrice != null) { - matched = matched.stream().filter(h -> { - BigDecimal p = h.getId() == null ? null : productMinMap.get(h.getId()); - if (p == null) { - return false; + boolean smallScope = scopedIds != null && !scopedIds.isEmpty() && scopedIds.size() <= MAX_SCOPED_HOTEL_IDS; + Page raw; + int candSize = -1; + if (smallScope) { + List candidateIds = scopedIds; + if (needCap > 1) { + List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacityInHotels(needCap, candidateIds); + if (capHotels == null || capHotels.isEmpty()) { + Page> empty = emptyPage(page); + writePageCache(pageCacheKey, empty); + return R.ok(empty); } - if (minPrice != null && p.compareTo(minPrice) < 0) { - return false; + Set capSet = new HashSet<>(capHotels); + candidateIds = candidateIds.stream().filter(capSet::contains).toList(); + if (candidateIds.isEmpty()) { + Page> empty = emptyPage(page); + writePageCache(pageCacheKey, empty); + return R.ok(empty); } - return maxPrice == null || p.compareTo(maxPrice) <= 0; - }).toList(); - if (matched.isEmpty()) { + } + Map productMinMap = queryProductMinPricesByIds(candidateIds); + if (minPrice != null || maxPrice != null) { + candidateIds = candidateIds.stream().filter(id -> inProductMinRange(productMinMap.get(id), minPrice, maxPrice)) + .toList(); + if (candidateIds.isEmpty()) { + Page> empty = emptyPage(page); + writePageCache(pageCacheKey, empty); + return R.ok(empty); + } + } + candSize = candidateIds.size(); + List matched = hotelService.list(baseOnlineListWrapper(keyword, city, district, + districtAdcode, bizArea, starList, minScore, facilityList) + .select(SmHotelEntity::getId, SmHotelEntity::getStarLevel) + .in(SmHotelEntity::getId, candidateIds)); + if (matched == null || matched.isEmpty()) { Page> empty = emptyPage(page); writePageCache(pageCacheKey, empty); return R.ok(empty); } + matched = new ArrayList<>(matched); + matched.sort(Comparator + .comparing((SmHotelEntity h) -> h.getStarLevel() == null ? 0 : h.getStarLevel()) + .reversed() + .thenComparing((SmHotelEntity h) -> productMinMap.getOrDefault(h.getId(), BigDecimal.ZERO), + Comparator.reverseOrder()) + .thenComparing((SmHotelEntity h) -> h.getId() == null ? 0L : h.getId(), Comparator.reverseOrder())); + raw = sliceWithoutCount(page, matched); + List pageIds = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); + raw.setRecords(loadListRowsInOrder(pageIds)); + } + else { + // 全国或匹配过多:只取一页,禁止 list 全表。排序用星级+id,避免产品相关子查询把请求拖死。 + var wrapper = applyListColumns(baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, + starList, minScore, facilityList)); + applyGuestCapacityFilter(wrapper, needCap); + applyMinProductPriceFilter(wrapper, minPrice, maxPrice); + wrapper.last("ORDER BY IFNULL(star_level, 0) DESC, id DESC"); + raw = pageWithoutCount(page, wrapper); } - // 五星优先,同档产品最低卖价从高到低,无星级/无价格视为 0(与原 SQL IFNULL 一致) - matched = new ArrayList<>(matched); - matched.sort(Comparator - .comparing((SmHotelEntity h) -> h.getStarLevel() == null ? 0 : h.getStarLevel()) - .reversed() - .thenComparing((SmHotelEntity h) -> productMinMap.getOrDefault(h.getId(), BigDecimal.ZERO), - Comparator.reverseOrder()) - .thenComparing((SmHotelEntity h) -> h.getId() == null ? 0L : h.getId(), Comparator.reverseOrder())); - Page raw = sliceWithoutCount(page, matched); List ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); - Map fullById = loadListColumnsByIds(ids); - List pageHotels = new ArrayList<>(ids.size()); - for (Long id : ids) { - SmHotelEntity h = fullById.get(id); - if (h != null) { - pageHotels.add(h); - } - } - raw.setRecords(pageHotels); LocalDate priceFrom = stayFrom != null ? stayFrom : LocalDate.now(); LocalDate priceToEx = stayToExclusive; if (priceToEx == null || !priceToEx.isAfter(priceFrom)) { @@ -322,10 +329,14 @@ public class SmHotelAppController { } Map minPriceMap = loadMinSellPrice(ids, priceFrom, priceToEx); Page> out = new Page<>(raw.getCurrent(), raw.getSize(), raw.getTotal()); - out.setRecords(pageHotels.stream().map(h -> toListRow(h, minPriceMap.get(h.getId()))).toList()); + out.setRecords(raw.getRecords().stream() + .filter(Objects::nonNull) + .map(h -> toListRow(h, minPriceMap.get(h.getId()))) + .toList()); writePageCache(pageCacheKey, out); - log.info("|酒店App|page|current={}|size={}|total={}|matched={}|costMs={}|", page.getCurrent(), page.getSize(), - raw.getTotal(), matchedIds.size(), System.currentTimeMillis() - t0); + log.info("|酒店App|page|current={}|size={}|total={}|scope={}|cand={}|costMs={}|", page.getCurrent(), + page.getSize(), raw.getTotal(), scopedIds == null ? -1 : scopedIds.size(), candSize, + System.currentTimeMillis() - t0); return R.ok(out); } @@ -349,162 +360,58 @@ public class SmHotelAppController { return out; } - private Map loadListColumnsByIds(List ids) { - Map map = new LinkedHashMap<>(); + private static LambdaQueryWrapper applyListColumns(LambdaQueryWrapper wrapper) { + return wrapper.select(SmHotelEntity::getId, SmHotelEntity::getHotelName, SmHotelEntity::getHotelCode, + SmHotelEntity::getAddress, SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, + SmHotelEntity::getBizArea, SmHotelEntity::getStarLevel, SmHotelEntity::getScore, + SmHotelEntity::getFacilityTags, SmHotelEntity::getCoverImage, SmHotelEntity::getPhone, + SmHotelEntity::getSheetName, SmHotelEntity::getUpstreamStatus, SmHotelEntity::getUpdateTime); + } + + private List loadListRowsInOrder(List ids) { if (ids == null || ids.isEmpty()) { - return map; + return List.of(); } - List rows = hotelService.list(Wrappers.lambdaQuery() - .in(SmHotelEntity::getId, ids) - .select(SmHotelEntity::getId, SmHotelEntity::getHotelName, SmHotelEntity::getHotelCode, - SmHotelEntity::getAddress, SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, - SmHotelEntity::getBizArea, SmHotelEntity::getStarLevel, SmHotelEntity::getScore, - SmHotelEntity::getFacilityTags, SmHotelEntity::getCoverImage, SmHotelEntity::getPhone, - SmHotelEntity::getSheetName, SmHotelEntity::getUpstreamStatus, SmHotelEntity::getUpdateTime)); - if (rows == null) { - return map; + List rows = hotelService.list(applyListColumns(Wrappers.lambdaQuery() + .in(SmHotelEntity::getId, ids))); + if (rows == null || rows.isEmpty()) { + return List.of(); } + Map byId = new HashMap<>(); for (SmHotelEntity h : rows) { if (h != null && h.getId() != null) { - map.put(h.getId(), h); + byId.put(h.getId(), h); } } - return map; + List ordered = new ArrayList<>(ids.size()); + for (Long id : ids) { + SmHotelEntity h = byId.get(id); + if (h != null) { + ordered.add(h); + } + } + return ordered; } - private List filterByGuestCapacity(List hotels, int needCap) { - List ids = hotels.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); - if (ids.isEmpty()) { - return List.of(); + private static boolean inProductMinRange(BigDecimal minSell, BigDecimal minPrice, BigDecimal maxPrice) { + if (minSell == null) { + return false; } - Set capSet; - if (ids.size() <= MAX_IN_HOTEL_IDS) { - List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacityInHotels(needCap, ids); - capSet = capHotels == null ? Set.of() : new HashSet<>(capHotels); + if (minPrice != null && minSell.compareTo(minPrice) < 0) { + return false; } - else { - capSet = loadCapacityHotelIdSet(needCap); - } - if (capSet.isEmpty()) { - return List.of(); - } - return hotels.stream().filter(h -> h.getId() != null && capSet.contains(h.getId())).toList(); - } - - private Set loadCapacityHotelIdSet(int needCap) { - String key = CAP_CACHE_PREFIX + needCap; - try { - String cached = stringRedisTemplate.opsForValue().get(key); - if (StrUtil.isNotBlank(cached)) { - if (CANDIDATE_CACHE_EMPTY.equals(cached)) { - return Set.of(); - } - return Arrays.stream(cached.split(",")) - .map(String::trim) - .filter(StrUtil::isNotBlank) - .map(Long::valueOf) - .collect(Collectors.toCollection(HashSet::new)); - } - } - catch (Exception e) { - log.warn("|酒店App|可住人数缓存读取失败|{}", e.getMessage()); - } - List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCap); - if (capHotels == null || capHotels.isEmpty()) { - try { - stringRedisTemplate.opsForValue().set(key, CANDIDATE_CACHE_EMPTY, CAP_CACHE_MINUTES, TimeUnit.MINUTES); - } - catch (Exception e) { - log.warn("|酒店App|可住人数缓存写入失败|{}", e.getMessage()); - } - return Set.of(); - } - Set set = capHotels.stream().filter(Objects::nonNull).collect(Collectors.toCollection(HashSet::new)); - try { - stringRedisTemplate.opsForValue() - .set(key, set.stream().map(String::valueOf).collect(Collectors.joining(",")), CAP_CACHE_MINUTES, - TimeUnit.MINUTES); - } - catch (Exception e) { - log.warn("|酒店App|可住人数缓存写入失败|{}", e.getMessage()); - } - return set; - } - - /** - * 产品最低卖价:小集合走 hotel_id IN(走 idx_hotel_id);大集合走全量 GROUP BY 短缓存。 - */ - private Map loadProductMinPrices(List hotelIds) { - if (hotelIds == null || hotelIds.isEmpty()) { - return Map.of(); - } - if (hotelIds.size() <= MAX_PMIN_IN_HOTELS) { - return queryProductMinPricesByIds(hotelIds); - } - Map all = loadAllProductMinPricesCached(); - if (all.isEmpty()) { - return Map.of(); - } - Map out = new HashMap<>(Math.min(hotelIds.size(), all.size())); - for (Long id : hotelIds) { - BigDecimal p = all.get(id); - if (p != null) { - out.put(id, p); - } - } - return out; + return maxPrice == null || minSell.compareTo(maxPrice) <= 0; } private Map queryProductMinPricesByIds(List hotelIds) { Map out = new HashMap<>(); + if (hotelIds == null || hotelIds.isEmpty()) { + return out; + } int batch = 800; for (int i = 0; i < hotelIds.size(); i += batch) { List part = hotelIds.subList(i, Math.min(i + batch, hotelIds.size())); - List> rows = productMapper.selectMinSellPriceByHotelIds(part); - putHotelMinPrices(out, rows); - } - return out; - } - - private Map loadAllProductMinPricesCached() { - try { - Map cached = stringRedisTemplate.opsForHash().entries(PMIN_CACHE_KEY); - if (cached != null && !cached.isEmpty()) { - Map out = new HashMap<>(cached.size()); - for (Map.Entry e : cached.entrySet()) { - if (e.getKey() == null || e.getValue() == null) { - continue; - } - String v = String.valueOf(e.getValue()); - if (StrUtil.isBlank(v)) { - continue; - } - try { - out.put(Long.valueOf(String.valueOf(e.getKey())), new BigDecimal(v)); - } - catch (Exception ignore) { - // skip bad cache field - } - } - return out; - } - } - catch (Exception e) { - log.warn("|酒店App|产品最低价缓存读取失败|{}", e.getMessage()); - } - List> rows = productMapper.selectAllHotelMinSellPrice(); - Map out = new HashMap<>(); - putHotelMinPrices(out, rows); - if (!out.isEmpty()) { - try { - Map hash = new HashMap<>(out.size()); - out.forEach((id, p) -> hash.put(String.valueOf(id), p.toPlainString())); - stringRedisTemplate.opsForHash().putAll(PMIN_CACHE_KEY, hash); - stringRedisTemplate.expire(PMIN_CACHE_KEY, PMIN_CACHE_MINUTES, TimeUnit.MINUTES); - } - catch (Exception e) { - log.warn("|酒店App|产品最低价缓存写入失败|{}", e.getMessage()); - } + putHotelMinPrices(out, productMapper.selectMinSellPriceByHotelIds(part)); } return out; } @@ -521,6 +428,50 @@ public class SmHotelAppController { } } + /** + * 起价筛选等价于 MIN(sell_price) 落在区间:有产品、没有更低越界价、且存在不超过上限的价。 + * 只挂在 LIMIT 分页 SQL 上,禁止全表 GROUP BY。 + */ + private void applyMinProductPriceFilter(LambdaQueryWrapper wrapper, BigDecimal minPrice, + BigDecimal maxPrice) { + if (minPrice == null && maxPrice == null) { + return; + } + if (minPrice != null) { + wrapper.apply(""" + NOT EXISTS ( + SELECT 1 FROM canteen_hotel_product p + WHERE p.hotel_id = canteen_hotel.id + AND p.del_flag = '0' + AND p.sell_price IS NOT NULL + AND p.sell_price < {0} + LIMIT 1 + ) + """, minPrice); + wrapper.apply(""" + EXISTS ( + SELECT 1 FROM canteen_hotel_product p + WHERE p.hotel_id = canteen_hotel.id + AND p.del_flag = '0' + AND p.sell_price IS NOT NULL + LIMIT 1 + ) + """); + } + if (maxPrice != null) { + wrapper.apply(""" + EXISTS ( + SELECT 1 FROM canteen_hotel_product p + WHERE p.hotel_id = canteen_hotel.id + AND p.del_flag = '0' + AND p.sell_price IS NOT NULL + AND p.sell_price <= {0} + LIMIT 1 + ) + """, maxPrice); + } + } + /** 列表只要一页,不做 COUNT(*)(全国 COUNT+EXISTS 会扫全表十几秒) */ private Page pageWithoutCount(Page page, LambdaQueryWrapper wrapper) { long size = Math.max(1L, Math.min(page.getSize(), 50L)); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java index 72b2e579..eb2fb125 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java @@ -61,15 +61,6 @@ public interface SmHotelProductMapper extends PjcloudBaseMapper selectHotelIdsByMinSellPriceRange(@Param("minPrice") BigDecimal minPrice, @Param("maxPrice") BigDecimal maxPrice); - /** 全库酒店最低卖价(短缓存热身,避免每次列表 GROUP BY 千万产品) */ - @Select(""" - SELECT hotel_id AS hotelId, MIN(sell_price) AS minSell - FROM canteen_hotel_product - WHERE del_flag = '0' AND sell_price IS NOT NULL - GROUP BY hotel_id - """) - List> selectAllHotelMinSellPrice(); - /** 本页酒店批量取最低卖价 */ @Select("""