This commit is contained in:
zjf committed 2026-10-22 22:32:47 +08:00
1 parent 617bd2ef24
commit d88f29f842
2 files changed
+261 -65

No files matched your search

@@ -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<Long> 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<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()) {
Page<Map<String, Object>> empty = emptyPage(page);
writePageCache(pageCacheKey, empty);
return R.ok(empty);
}
}
Page<SmHotelEntity> raw;
int candSize = -1;
boolean smallScope = scopedIds != null && !scopedIds.isEmpty() && scopedIds.size() <= MAX_SCOPED_HOTEL_IDS;
if (smallScope) {
List<Long> candidateIds = scopedIds;
if (needCap > 1) {
List<Long> capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCap);
if (capHotels == null || capHotels.isEmpty()) {
return R.ok(emptyPage(page));
}
Set<Long> 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<Long> matchedIds = matched.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList();
Map<Long, BigDecimal> productMinMap = loadProductMinPrices(matchedIds);
if (minPrice != null || maxPrice != null) {
List<Long> 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<Map<String, Object>> 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<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)) {
@@ -317,14 +322,10 @@ 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(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<SmHotelEntity> sliceWithoutCount(Page<SmHotelEntity> page, List<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> out = new Page<>(current, size, total);
out.setRecords(new ArrayList<>(slice));
return out;
}
private Map<Long, SmHotelEntity> loadListColumnsByIds(List<Long> ids) {
Map<Long, SmHotelEntity> map = new LinkedHashMap<>();
if (ids == null || ids.isEmpty()) {
return map;
}
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;
}
for (SmHotelEntity h : rows) {
if (h != null && h.getId() != null) {
map.put(h.getId(), h);
}
}
return map;
}
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();
}
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);
}
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;
}
private Map<Long, BigDecimal> queryProductMinPricesByIds(List<Long> hotelIds) {
Map<Long, BigDecimal> out = new HashMap<>();
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());
}
}
return out;
}
private static void putHotelMinPrices(Map<Long, BigDecimal> out, List<Map<String, Object>> rows) {
if (rows == null) {
return;
}
for (Map<String, Object> 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<SmHotelEntity> pageWithoutCount(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper) {
long size = Math.max(1L, Math.min(page.getSize(), 50L));
@@ -61,6 +61,15 @@ 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>