This commit is contained in:
zjf committed 2026-10-22 22:42:41 +08:00
1 parent d88f29f842
commit aa766987f9
2 files changed
+149 -207

No files matched your search

@@ -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<SmHotelEntity> matched = hotelService.list(idWrapper);
if (matched == null || matched.isEmpty()) {
Page<Map<String, Object>> 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<Long> scopedIds = null;
if (hasScopeFilter) {
scopedIds = loadScopedHotelIds(keyword, city, district, districtAdcode, bizArea, starList, minScore,
facilityList);
if (scopedIds != null && scopedIds.isEmpty()) {
Page<Map<String, Object>> empty = emptyPage(page);
writePageCache(pageCacheKey, empty);
return R.ok(empty);
}
}
List<Long> matchedIds = matched.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
Map<Long, BigDecimal> 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<SmHotelEntity> raw;
int candSize = -1;
if (smallScope) {
List<Long> candidateIds = scopedIds;
if (needCap > 1) {
List<Long> capHotels = roomMapper.selectDistinctHotelIdsByMinCapacityInHotels(needCap, candidateIds);
if (capHotels == null || capHotels.isEmpty()) {
Page<Map<String, Object>> empty = emptyPage(page);
writePageCache(pageCacheKey, empty);
return R.ok(empty);
}
if (minPrice != null && p.compareTo(minPrice) < 0) {
return false;
Set<Long> capSet = new HashSet<>(capHotels);
candidateIds = candidateIds.stream().filter(capSet::contains).toList();
if (candidateIds.isEmpty()) {
Page<Map<String, Object>> empty = emptyPage(page);
writePageCache(pageCacheKey, empty);
return R.ok(empty);
}
return maxPrice == null || p.compareTo(maxPrice) <= 0;
}).toList();
if (matched.isEmpty()) {
}
Map<Long, BigDecimal> productMinMap = queryProductMinPricesByIds(candidateIds);
if (minPrice != null || maxPrice != null) {
candidateIds = candidateIds.stream().filter(id -> inProductMinRange(productMinMap.get(id), minPrice, maxPrice))
.toList();
if (candidateIds.isEmpty()) {
Page<Map<String, Object>> empty = emptyPage(page);
writePageCache(pageCacheKey, empty);
return R.ok(empty);
}
}
candSize = candidateIds.size();
List<SmHotelEntity> 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<Map<String, Object>> 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<Long> 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<SmHotelEntity> raw = sliceWithoutCount(page, matched);
List<Long> ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
Map<Long, SmHotelEntity> fullById = loadListColumnsByIds(ids);
List<SmHotelEntity> 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<Long, BigDecimal> minPriceMap = loadMinSellPrice(ids, priceFrom, priceToEx);
Page<Map<String, Object>> 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<Long, SmHotelEntity> loadListColumnsByIds(List<Long> ids) {
Map<Long, SmHotelEntity> map = new LinkedHashMap<>();
private static LambdaQueryWrapper<SmHotelEntity> applyListColumns(LambdaQueryWrapper<SmHotelEntity> 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<SmHotelEntity> loadListRowsInOrder(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return map;
return List.of();
}
List<SmHotelEntity> rows = hotelService.list(Wrappers.<SmHotelEntity>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<SmHotelEntity> rows = hotelService.list(applyListColumns(Wrappers.<SmHotelEntity>lambdaQuery()
.in(SmHotelEntity::getId, ids)));
if (rows == null || rows.isEmpty()) {
return List.of();
}
Map<Long, SmHotelEntity> 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<SmHotelEntity> ordered = new ArrayList<>(ids.size());
for (Long id : ids) {
SmHotelEntity h = byId.get(id);
if (h != null) {
ordered.add(h);
}
}
return ordered;
}
private List<SmHotelEntity> filterByGuestCapacity(List<SmHotelEntity> hotels, int needCap) {
List<Long> 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<Long> capSet;
if (ids.size() <= MAX_IN_HOTEL_IDS) {
List<Long> 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<Long> 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<Long> 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<Long> 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<Long, BigDecimal> loadProductMinPrices(List<Long> hotelIds) {
if (hotelIds == null || hotelIds.isEmpty()) {
return Map.of();
}
if (hotelIds.size() <= MAX_PMIN_IN_HOTELS) {
return queryProductMinPricesByIds(hotelIds);
}
Map<Long, BigDecimal> all = loadAllProductMinPricesCached();
if (all.isEmpty()) {
return Map.of();
}
Map<Long, BigDecimal> 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<Long, BigDecimal> queryProductMinPricesByIds(List<Long> hotelIds) {
Map<Long, BigDecimal> out = new HashMap<>();
if (hotelIds == null || hotelIds.isEmpty()) {
return out;
}
int batch = 800;
for (int i = 0; i < hotelIds.size(); i += batch) {
List<Long> part = hotelIds.subList(i, Math.min(i + batch, hotelIds.size()));
List<Map<String, Object>> rows = productMapper.selectMinSellPriceByHotelIds(part);
putHotelMinPrices(out, rows);
}
return out;
}
private Map<Long, BigDecimal> loadAllProductMinPricesCached() {
try {
Map<Object, Object> cached = stringRedisTemplate.opsForHash().entries(PMIN_CACHE_KEY);
if (cached != null && !cached.isEmpty()) {
Map<Long, BigDecimal> out = new HashMap<>(cached.size());
for (Map.Entry<Object, Object> 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<Map<String, Object>> rows = productMapper.selectAllHotelMinSellPrice();
Map<Long, BigDecimal> out = new HashMap<>();
putHotelMinPrices(out, rows);
if (!out.isEmpty()) {
try {
Map<String, String> 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<SmHotelEntity> 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<SmHotelEntity> pageWithoutCount(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper) {
long size = Math.max(1L, Math.min(page.getSize(), 50L));
@@ -61,15 +61,6 @@ public interface SmHotelProductMapper extends PjcloudBaseMapper<SmHotelProductEn
List<Long> 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<Map<String, Object>> selectAllHotelMinSellPrice();
/** 本页酒店批量取最低卖价 */
@Select("""
<script>