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 d3df95d5..17b5a7cc 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 @@ -40,7 +40,9 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -87,13 +89,25 @@ public class SmHotelAppController { private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW"; /** 列表页结果短缓存(同条件重复进入/下拉) */ - private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v7:"; + private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v8:"; 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; + private final SmHotelService hotelService; private final SmHotelMidService midService; @@ -246,70 +260,61 @@ public class SmHotelAppController { return R.ok(cached); } - var wrapper = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore, + var idWrapper = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore, facilityList); - // 列表不查 intro/carousel 大字段,显著降低 IO 与加密体积 - 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); - - 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()) { - return R.ok(emptyPage(page)); + 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()) { + Page> empty = emptyPage(page); + writePageCache(pageCacheKey, empty); + return R.ok(empty); } } - - Page raw; - int candSize = -1; - boolean smallScope = scopedIds != null && !scopedIds.isEmpty() && scopedIds.size() <= MAX_SCOPED_HOTEL_IDS; - if (smallScope) { - List candidateIds = scopedIds; - if (needCap > 1) { - List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCap); - if (capHotels == null || capHotels.isEmpty()) { - return R.ok(emptyPage(page)); - } - Set capSet = new java.util.HashSet<>(capHotels); - candidateIds = scopedIds.stream().filter(capSet::contains).toList(); - if (candidateIds.isEmpty()) { - return R.ok(emptyPage(page)); - } - } - candSize = candidateIds.size(); - wrapper.in(SmHotelEntity::getId, candidateIds); - } - else { - // 全国:上架酒店都出列表;所选日期无日历的在行上标已售罄,不再按可订日历过滤掉 - applyGuestCapacityFilter(wrapper, needCap); - } + List matchedIds = matched.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); + Map productMinMap = loadProductMinPrices(matchedIds); if (minPrice != null || maxPrice != null) { - List priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice); - if (priceHitIds.isEmpty()) { - return R.ok(emptyPage(page)); + matched = matched.stream().filter(h -> { + BigDecimal p = h.getId() == null ? null : productMinMap.get(h.getId()); + if (p == null) { + return false; + } + if (minPrice != null && p.compareTo(minPrice) < 0) { + return false; + } + return maxPrice == null || p.compareTo(maxPrice) <= 0; + }).toList(); + if (matched.isEmpty()) { + Page> empty = emptyPage(page); + writePageCache(pageCacheKey, empty); + return R.ok(empty); } - wrapper.in(SmHotelEntity::getId, priceHitIds); } - // 五星档次优先,同档按产品标价从高到低(无星级/无价格的靠后) - wrapper.last(""" - ORDER BY IFNULL(star_level, 0) DESC, - IFNULL(( - SELECT MIN(p.sell_price) FROM canteen_hotel_product p - WHERE p.del_flag = '0' - AND p.hotel_id = canteen_hotel.id - AND p.sell_price IS NOT NULL - ), 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)) { @@ -317,14 +322,10 @@ public class SmHotelAppController { } Map minPriceMap = loadMinSellPrice(ids, priceFrom, priceToEx); Page> out = new Page<>(raw.getCurrent(), raw.getSize(), raw.getTotal()); - out.setRecords(raw.getRecords().stream() - .filter(Objects::nonNull) - .map(h -> toListRow(h, minPriceMap.get(h.getId()))) - .toList()); + out.setRecords(pageHotels.stream().map(h -> toListRow(h, minPriceMap.get(h.getId()))).toList()); writePageCache(pageCacheKey, out); - 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); + log.info("|酒店App|page|current={}|size={}|total={}|matched={}|costMs={}|", page.getCurrent(), page.getSize(), + raw.getTotal(), matchedIds.size(), System.currentTimeMillis() - t0); return R.ok(out); } @@ -334,6 +335,192 @@ public class SmHotelAppController { return empty; } + /** 内存分页:语义与 pageWithoutCount 一致,不做 COUNT(*) */ + private static Page sliceWithoutCount(Page page, List sorted) { + long size = Math.max(1L, Math.min(page.getSize(), 50L)); + long current = Math.max(1L, page.getCurrent()); + int from = (int) Math.min((current - 1) * size, sorted.size()); + int to = (int) Math.min(from + size, sorted.size()); + List slice = from >= sorted.size() ? List.of() : sorted.subList(from, to); + boolean hasMore = sorted.size() > to; + long total = (current - 1) * size + slice.size() + (hasMore ? 1 : 0); + Page out = new Page<>(current, size, total); + out.setRecords(new ArrayList<>(slice)); + return out; + } + + private Map loadListColumnsByIds(List ids) { + Map map = new LinkedHashMap<>(); + if (ids == null || ids.isEmpty()) { + return map; + } + 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; + } + for (SmHotelEntity h : rows) { + if (h != null && h.getId() != null) { + map.put(h.getId(), h); + } + } + return map; + } + + private List filterByGuestCapacity(List hotels, int needCap) { + List ids = hotels.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); + if (ids.isEmpty()) { + return List.of(); + } + Set capSet; + if (ids.size() <= MAX_IN_HOTEL_IDS) { + List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacityInHotels(needCap, ids); + capSet = capHotels == null ? Set.of() : new HashSet<>(capHotels); + } + 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; + } + + private Map queryProductMinPricesByIds(List hotelIds) { + Map out = new HashMap<>(); + 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()); + } + } + return out; + } + + private static void putHotelMinPrices(Map out, List> rows) { + if (rows == null) { + return; + } + for (Map row : rows) { + if (row == null || row.get("hotelId") == null || row.get("minSell") == null) { + continue; + } + out.put(Long.valueOf(String.valueOf(row.get("hotelId"))), new BigDecimal(String.valueOf(row.get("minSell")))); + } + } + /** 列表只要一页,不做 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 eb2fb125..72b2e579 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,6 +61,15 @@ 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("""