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..3560e123 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 @@ -17,6 +17,7 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelUserSearchPrefEntity; import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper; import com.bsd.pjcloud.supermarket.mapper.SmHotelUserSearchPrefMapper; @@ -40,7 +41,10 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +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,15 +91,24 @@ 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:v13:"; private static final long PAGE_CACHE_SECONDS = 45L; - /** 先按区域/关键字缩小酒店集合时的上限,超出则回退 EXISTS */ - private static final int MAX_SCOPED_HOTEL_IDS = 3000; + private static final String CITY_ADCODE_CACHE_PREFIX = "sm:hotel:app:city-adc:v1:"; + + private static final long CITY_ADCODE_CACHE_HOURS = 24L; + + /** 城市/关键字等缩小后,在该规模内内存排序(超出则 SQL LIMIT,禁止全表 list) */ + private static final int MAX_SCOPED_HOTEL_IDS = 8000; + + /** 店名包含搜索最多捞取条数(禁止 ORDER BY 全表 filesort) */ + private static final int KEYWORD_CONTAINS_FETCH = 80; private final SmHotelService hotelService; + private final SmHotelMapper hotelMapper; + private final SmHotelMidService midService; private final SmHotelOrderService orderService; @@ -146,45 +159,21 @@ public class SmHotelAppController { } /** - * 筛选项:按列 GROUP BY,避免全表加载酒店实体;districts 仅兜底(App 优先走行政区划接口)。 + * 筛选项:区域走 sysArea,不再对 canteen_hotel 做全表 GROUP BY(会扫十几秒并和列表抢锁)。 */ private Map buildFilterMeta() { - Set districts = new LinkedHashSet<>(); - for (SmHotelEntity h : hotelService.list(onlineHotelWrapper() - .select(SmHotelEntity::getDistrict) - .isNotNull(SmHotelEntity::getDistrict) - .ne(SmHotelEntity::getDistrict, "") - .groupBy(SmHotelEntity::getDistrict))) { - if (h != null && StrUtil.isNotBlank(h.getDistrict())) { - districts.add(h.getDistrict().trim()); - } - } Set bizAreas = new LinkedHashSet<>(); for (SmHotelEntity h : hotelService.list(onlineHotelWrapper() .select(SmHotelEntity::getBizArea) .isNotNull(SmHotelEntity::getBizArea) .ne(SmHotelEntity::getBizArea, "") - .groupBy(SmHotelEntity::getBizArea))) { + .groupBy(SmHotelEntity::getBizArea) + .last("LIMIT 300"))) { if (h != null && StrUtil.isNotBlank(h.getBizArea())) { bizAreas.add(h.getBizArea().trim()); } } Set facilities = new LinkedHashSet<>(DEFAULT_FACILITY_OPTIONS); - for (SmHotelEntity h : hotelService.list(onlineHotelWrapper() - .select(SmHotelEntity::getFacilityTags) - .isNotNull(SmHotelEntity::getFacilityTags) - .ne(SmHotelEntity::getFacilityTags, "") - .groupBy(SmHotelEntity::getFacilityTags) - .last("LIMIT 2000"))) { - if (h == null || StrUtil.isBlank(h.getFacilityTags())) { - continue; - } - for (String t : h.getFacilityTags().split("[,,]")) { - if (StrUtil.isNotBlank(t)) { - facilities.add(t.trim()); - } - } - } List> starLevels = new ArrayList<>(); STAR_LABEL.forEach((k, v) -> { Map item = new LinkedHashMap<>(); @@ -194,7 +183,7 @@ public class SmHotelAppController { }); starLevels.sort((a, b) -> Integer.compare((Integer) b.get("value"), (Integer) a.get("value"))); Map out = new LinkedHashMap<>(); - out.put("districts", new ArrayList<>(districts)); + out.put("districts", List.of()); out.put("bizAreas", new ArrayList<>(bizAreas)); out.put("starLevels", starLevels); out.put("facilityTags", new ArrayList<>(facilities)); @@ -246,69 +235,30 @@ public class SmHotelAppController { return R.ok(cached); } - var wrapper = 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)); - } - } - 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); + if (StrUtil.isNotBlank(keyword)) { + // 店名搜索专用路径:禁止「LIKE %kw% + ORDER BY」全表 filesort(搜「小如」会超时变空) + raw = pageByHotelNameKeyword(page, keyword.trim(), city, district, districtAdcode, starList); } else { - // 全国:上架酒店都出列表;所选日期无日历的在行上标已售罄,不再按可订日历过滤掉 - applyGuestCapacityFilter(wrapper, needCap); - } - if (minPrice != null || maxPrice != null) { - List priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice); - if (priceHitIds.isEmpty()) { - return R.ok(emptyPage(page)); + boolean hasArea = districtAdcode != null || StrUtil.isNotBlank(city) || StrUtil.isNotBlank(district); + var wrapper = applyListColumns(baseOnlineListWrapper(null, city, district, districtAdcode, bizArea, + starList, minScore, facilityList, true)); + if (hasArea) { + applyGuestCapacityFilter(wrapper, needCap); + applyMinProductPriceFilter(wrapper, minPrice, maxPrice); + } + wrapper.last("ORDER BY star_level DESC, id DESC"); + raw = pageWithoutCount(page, wrapper); + int got = raw.getRecords() == null ? 0 : raw.getRecords().size(); + if (hasArea && got < Math.max(1L, Math.min(page.getSize(), 50L)) && page.getCurrent() <= 1) { + String uncodedName = StrUtil.firstNonBlank(city, district); + if (StrUtil.isNotBlank(uncodedName)) { + fillUncodedHotels(raw, uncodedName, null, bizArea, starList, minScore, facilityList, needCap, + minPrice, maxPrice); + } } - 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); List ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); LocalDate priceFrom = stayFrom != null ? stayFrom : LocalDate.now(); LocalDate priceToEx = stayToExclusive; @@ -322,9 +272,8 @@ public class SmHotelAppController { .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={}|costMs={}|", page.getCurrent(), page.getSize(), + raw.getTotal(), System.currentTimeMillis() - t0); return R.ok(out); } @@ -334,6 +283,132 @@ 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 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 List.of(); + } + 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) { + byId.put(h.getId(), h); + } + } + List ordered = new ArrayList<>(ids.size()); + for (Long id : ids) { + SmHotelEntity h = byId.get(id); + if (h != null) { + ordered.add(h); + } + } + return ordered; + } + + private static boolean inProductMinRange(BigDecimal minSell, BigDecimal minPrice, BigDecimal maxPrice) { + if (minSell == null) { + return false; + } + if (minPrice != null && minSell.compareTo(minPrice) < 0) { + return false; + } + 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())); + putHotelMinPrices(out, productMapper.selectMinSellPriceByHotelIds(part)); + } + 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")))); + } + } + + /** + * 起价筛选等价于 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)); @@ -349,35 +424,100 @@ public class SmHotelAppController { return out; } + /** + * 按酒店名搜索: + * 1) 前缀匹配(idx_hotel_name + ORDER BY,快); + * 2) 无结果再含匹配——SQL 不做 ORDER BY,只 LIMIT,再内存排序(避免超时变空列表)。 + * 有城市时尽量落到 district_adcode 区间。 + */ + private Page pageByHotelNameKeyword(Page page, String keyword, String city, + String district, Long districtAdcode, List starList) { + long size = Math.max(1L, Math.min(page.getSize(), 50L)); + long current = Math.max(1L, page.getCurrent()); + int fetch = (int) Math.min(KEYWORD_CONTAINS_FETCH, Math.max(size + 1, size * current + 1)); + Long adFrom = null; + Long adTo = null; + Long adcode = districtAdcode; + if (adcode == null && StrUtil.isNotBlank(city)) { + adcode = resolvePlaceAdcode(city.trim(), true); + } + if (adcode == null && StrUtil.isNotBlank(district) && StrUtil.isBlank(city)) { + adcode = resolvePlaceAdcode(district.trim(), false); + } + if (adcode != null) { + long[] range = HotelAreaAdcodeUtil.areaAdcodeRange(adcode); + adFrom = range[0]; + adTo = range[1]; + } + Collection stars = starList == null || starList.isEmpty() ? null : starList; + List rows = hotelMapper.selectAppByNamePrefix(keyword, adFrom, adTo, stars, fetch); + if (rows == null || rows.isEmpty()) { + // 含匹配绝不带 ORDER BY;有城市时区间小,全国则最多捞 KEYWORD_CONTAINS_FETCH 条后内存排 + rows = hotelMapper.selectAppByNameContains(keyword, adFrom, adTo, stars, fetch); + } + if (rows == null) { + rows = List.of(); + } + List sorted = new ArrayList<>(rows); + sorted.sort(Comparator + .comparing((SmHotelEntity h) -> h.getStarLevel() == null ? 0 : h.getStarLevel()) + .reversed() + .thenComparing((SmHotelEntity h) -> h.getId() == null ? 0L : h.getId(), Comparator.reverseOrder())); + 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 || sorted.size() >= fetch; + 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 LambdaQueryWrapper baseOnlineListWrapper(String keyword, String city, String district, Long districtAdcode, String bizArea, List starList, BigDecimal minScore, List facilityList) { + return baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore, + facilityList, true); + } + + /** + * @param namePrefix true=酒店名走前缀(可用 idx_hotel_name);false=含匹配(仅前缀无结果时) + */ + private LambdaQueryWrapper baseOnlineListWrapper(String keyword, String city, String district, + Long districtAdcode, String bizArea, List starList, BigDecimal minScore, + List facilityList, boolean namePrefix) { var wrapper = Wrappers.lambdaQuery() .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() - .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) - .and(StrUtil.isNotBlank(keyword), w -> w.like(SmHotelEntity::getHotelName, keyword.trim()) - .or() - .like(SmHotelEntity::getAddress, keyword.trim()) - .or() - .like(SmHotelEntity::getDistrict, keyword.trim()) - .or() - .like(SmHotelEntity::getBizArea, keyword.trim()) - .or() - .like(SmHotelEntity::getHotelCode, keyword.trim())) - .and(districtAdcode == null && StrUtil.isNotBlank(city), - w -> w.like(SmHotelEntity::getAddress, city.trim()) - .or() - .like(SmHotelEntity::getDistrict, city.trim()) - .or() - .like(SmHotelEntity::getHotelName, city.trim())); - if (districtAdcode != null) { - HotelAreaAdcodeUtil.applyHotelListFilter(wrapper, districtAdcode, district); + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")); + Long adcode = districtAdcode; + if (adcode == null && StrUtil.isNotBlank(city)) { + adcode = resolvePlaceAdcode(city.trim(), true); + } + if (adcode != null) { + long[] range = HotelAreaAdcodeUtil.areaAdcodeRange(adcode); + wrapper.apply("district_adcode >= {0} AND district_adcode < {1}", range[0], range[1]); + } + else if (StrUtil.isNotBlank(city)) { + applyDistrictPrefix(wrapper, city.trim()); } else if (StrUtil.isNotBlank(district)) { wrapper.eq(SmHotelEntity::getDistrict, StrUtil.trim(district)); } + if (StrUtil.isNotBlank(keyword)) { + String kw = keyword.trim(); + if (namePrefix) { + wrapper.and(w -> w.likeRight(SmHotelEntity::getHotelName, kw) + .or() + .eq(SmHotelEntity::getHotelCode, kw)); + } + else { + wrapper.and(w -> w.like(SmHotelEntity::getHotelName, kw) + .or() + .eq(SmHotelEntity::getHotelCode, kw)); + } + } wrapper.like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, StrUtil.trim(bizArea)) .in(!starList.isEmpty(), SmHotelEntity::getStarLevel, starList) .ge(minScore != null, SmHotelEntity::getScore, minScore); @@ -387,6 +527,168 @@ public class SmHotelAppController { return wrapper; } + /** 首页不足一页时,只补 district_adcode 为空的老数据,不并进主查询以免拖垮索引 */ + private void fillUncodedHotels(Page raw, String placeName, String keyword, String bizArea, + List starList, BigDecimal minScore, List facilityList, int needCap, + BigDecimal minPrice, BigDecimal maxPrice) { + int size = (int) Math.max(1L, Math.min(raw.getSize(), 50L)); + int need = size - (raw.getRecords() == null ? 0 : raw.getRecords().size()); + if (need <= 0 || StrUtil.isBlank(placeName)) { + return; + } + Set have = new HashSet<>(); + if (raw.getRecords() != null) { + for (SmHotelEntity h : raw.getRecords()) { + if (h != null && h.getId() != null) { + have.add(h.getId()); + } + } + } + String name = placeName.trim(); + String shortName = HotelAreaAdcodeUtil.shortenAreaName(name); + var w = applyListColumns(Wrappers.lambdaQuery() + .and(x -> x.isNull(SmHotelEntity::getUpstreamStatus) + .or() + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) + .isNull(SmHotelEntity::getDistrictAdcode) + .and(p -> { + p.like(SmHotelEntity::getDistrict, name) + .or() + .like(SmHotelEntity::getHotelName, name) + .or() + .like(SmHotelEntity::getAddress, name); + if (StrUtil.isNotBlank(shortName) && !shortName.equals(name)) { + p.or() + .like(SmHotelEntity::getDistrict, shortName) + .or() + .like(SmHotelEntity::getHotelName, shortName) + .or() + .like(SmHotelEntity::getAddress, shortName); + } + })); + if (StrUtil.isNotBlank(keyword) && !keyword.trim().equals(name)) { + String kw = keyword.trim(); + w.and(x -> x.like(SmHotelEntity::getHotelName, kw).or().like(SmHotelEntity::getHotelCode, kw)); + } + w.like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, StrUtil.trim(bizArea)) + .in(!starList.isEmpty(), SmHotelEntity::getStarLevel, starList) + .ge(minScore != null, SmHotelEntity::getScore, minScore); + for (String tag : facilityList) { + w.like(SmHotelEntity::getFacilityTags, tag); + } + applyGuestCapacityFilter(w, needCap); + applyMinProductPriceFilter(w, minPrice, maxPrice); + w.orderByDesc(SmHotelEntity::getStarLevel).orderByDesc(SmHotelEntity::getId).last("LIMIT " + (need + 5)); + List extra = hotelService.list(w); + if (extra == null || extra.isEmpty()) { + return; + } + List merged = new ArrayList<>(raw.getRecords() == null ? List.of() : raw.getRecords()); + for (SmHotelEntity h : extra) { + if (h == null || h.getId() == null || have.contains(h.getId())) { + continue; + } + merged.add(h); + have.add(h.getId()); + if (merged.size() >= size) { + break; + } + } + raw.setRecords(merged); + raw.setTotal(merged.size() + (merged.size() >= size ? 1 : 0)); + } + + private static void applyDistrictPrefix(LambdaQueryWrapper wrapper, String name) { + String shortName = HotelAreaAdcodeUtil.shortenAreaName(name); + wrapper.and(w -> { + w.likeRight(SmHotelEntity::getDistrict, name); + if (StrUtil.isNotBlank(shortName) && !shortName.equals(name)) { + w.or().likeRight(SmHotelEntity::getDistrict, shortName); + } + }); + } + + /** + * 地名 → adcode(缓存 24h)。从已落编码的酒店反推。 + * @param promoteToCity true=收到市级(出行城市);false=保留区县(搜索框地名) + */ + private Long resolvePlaceAdcode(String place, boolean promoteToCity) { + if (StrUtil.isBlank(place)) { + return null; + } + String name = place.trim(); + String cacheKey = CITY_ADCODE_CACHE_PREFIX + (promoteToCity ? "c:" : "p:") + name; + try { + String cached = stringRedisTemplate.opsForValue().get(cacheKey); + if ("-".equals(cached)) { + return null; + } + if (StrUtil.isNotBlank(cached)) { + return Long.valueOf(cached); + } + } + catch (Exception e) { + log.warn("|酒店App|地名adcode缓存读取失败|{}", e.getMessage()); + } + // 只精确匹配区名,禁止 LIKE 全表扫(否则选城市本身就会卡死) + String shortName = HotelAreaAdcodeUtil.shortenAreaName(name); + List rows = hotelService.list(Wrappers.lambdaQuery() + .select(SmHotelEntity::getDistrictAdcode, SmHotelEntity::getDistrict) + .isNotNull(SmHotelEntity::getDistrictAdcode) + .and(w -> { + w.eq(SmHotelEntity::getDistrict, name) + .or() + .eq(SmHotelEntity::getDistrict, name + "市") + .or() + .eq(SmHotelEntity::getDistrict, name + "区") + .or() + .eq(SmHotelEntity::getDistrict, name + "县"); + if (StrUtil.isNotBlank(shortName) && !shortName.equals(name)) { + w.or().eq(SmHotelEntity::getDistrict, shortName); + } + }) + .last("LIMIT 20")); + Long adcode = pickPlaceAdcode(rows, promoteToCity); + try { + stringRedisTemplate.opsForValue() + .set(cacheKey, adcode == null ? "-" : String.valueOf(adcode), CITY_ADCODE_CACHE_HOURS, + TimeUnit.HOURS); + } + catch (Exception e) { + log.warn("|酒店App|地名adcode缓存写入失败|{}", e.getMessage()); + } + return adcode; + } + + private static Long pickPlaceAdcode(List rows, boolean promoteToCity) { + if (rows == null || rows.isEmpty()) { + return null; + } + Map votes = new HashMap<>(); + for (SmHotelEntity h : rows) { + if (h == null || h.getDistrictAdcode() == null) { + continue; + } + long code = promoteToCity ? toCityAdcode(h.getDistrictAdcode()) : h.getDistrictAdcode(); + votes.merge(code, 1, Integer::sum); + } + return votes.entrySet() + .stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(null); + } + + private static long toCityAdcode(long adcode) { + if (adcode >= 100000 && adcode % 10000 == 0) { + return adcode; + } + if (adcode >= 100000 && adcode % 100 == 0) { + return adcode; + } + return (adcode / 100) * 100; + } + /** * 先按区域/关键字等缩小酒店 id;过大则返回 null(调用方走 EXISTS,避免超长 IN)。 */ diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java index 2c77ead8..68c9a2d9 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java @@ -99,12 +99,16 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner { log.warn("|回调队列|非法 taskId={}|", taskId); continue; } - boolean ok = callbackTaskService.processQueuedTask(id); + int result = callbackTaskService.processQueuedTask(id); + if (result == 0) { + // 残留 SUCCESS 副本:不限流、不打间隔,尽快排空以免占连接 + continue; + } int interval = Math.max(0, p.getCallbackTaskIntervalMs()); if (interval > 0) { Thread.sleep(interval); } - if (ok) { + if (result > 0) { consecutiveFail = 0; } else { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java index c226d329..a1ed9497 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelApi.java @@ -44,6 +44,9 @@ public final class ShangyouHotelApi { /** 回调异步队列(LIST,元素=taskId):房价/房量等普通优先级 */ public static final String REDIS_CALLBACK_QUEUE = "supermarket:shangyou:hotel:callback:queue"; + /** 已在队列中的 taskId(防重复 leftPush,避免 SUCCESS 副本每次启动再扫一遍) */ + public static final String REDIS_CALLBACK_QUEUED = "supermarket:shangyou:hotel:callback:queued"; + /** 回调高优先级队列:订单状态等,Worker 始终先消费本队列 */ public static final String REDIS_CALLBACK_QUEUE_HIGH = "supermarket:shangyou:hotel:callback:queue:high"; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java index 337e97b3..ac7a066c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelMapper.java @@ -4,11 +4,69 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.Collection; import java.util.List; @Mapper public interface SmHotelMapper extends PjcloudBaseMapper { int insertBatch(@Param("list") List list); + + /** + * App 店名前缀搜索:走 idx_hotel_name,可带区划区间;带 ORDER BY 安全(前缀命中集小)。 + */ + @Select(""" + + """) + List selectAppByNamePrefix(@Param("keyword") String keyword, @Param("adFrom") Long adFrom, + @Param("adTo") Long adTo, @Param("starLevels") Collection starLevels, + @Param("limit") int limit); + + /** + * App 店名包含搜索:禁止 ORDER BY(含匹配 + 排序 = 全表 filesort 超时)。 + * 先 LIMIT 取候选,由调用方内存排序。有 adcode 时扫描范围极小。 + */ + @Select(""" + + """) + List selectAppByNameContains(@Param("keyword") String keyword, @Param("adFrom") Long adFrom, + @Param("adTo") Long adTo, @Param("starLevels") Collection starLevels, + @Param("limit") int limit); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java index 2f8918f3..b7ca241b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java @@ -50,8 +50,11 @@ public interface SmHotelCallbackTaskService { /** 清空全部回调任务,并清空 Redis 消费队列 */ R clearAllTasks(); - /** 消费一条队列任务(Worker 调用);返回 true=本次成功/跳过,false=失败或待再试 */ - boolean processQueuedTask(Long taskId); + /** + * 消费一条队列任务(Worker 调用)。 + * @return 1 已处理成功,0 跳过(非 PENDING / 不存在),-1 失败待重试 + */ + int processQueuedTask(Long taskId); /** 启动/人工:把全部 PENDING + 超时 RUNNING 重新入队 */ int requeueStalePending(); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java index 5b02b50f..579337fa 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java @@ -63,6 +63,9 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic private final StringRedisTemplate redisTemplate; + /** Redis 残留 SUCCESS 副本跳过计数(避免启动刷屏) */ + private long skipNonPendingCount; + /** 与 Worker 一致:读渠道库表覆盖后的运行时,不要只读 YAML 默认值 */ private ShangyouHotelProperties props() { return channelConfigService.resolveShangyouRuntime(); @@ -311,6 +314,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic Long qHigh = redisTemplate.opsForList().size(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH); redisTemplate.delete(ShangyouHotelApi.REDIS_CALLBACK_QUEUE); redisTemplate.delete(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH); + redisTemplate.delete(ShangyouHotelApi.REDIS_CALLBACK_QUEUED); log.warn("|回调任务|清空全部|deleted={}|queueCleared≈{}/high≈{}|", n, qLen, qHigh); return R.ok(n, "已清空 " + n + " 条任务,并清理队列" + (cnt != null && cnt > n ? "(处理中任务未删)" : "")); @@ -339,14 +343,14 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic } @Override - public boolean processQueuedTask(Long taskId) { + public int processQueuedTask(Long taskId) { if (taskId == null) { - return true; + return 0; } ShangyouHotelProperties p = props(); Long tenantId = p.getCallbackTenantId() == null ? 1L : p.getCallbackTenantId(); - Boolean ok = TenantBroker.applyAs(tenantId, tid -> doProcess(taskId)); - return Boolean.TRUE.equals(ok); + Integer r = TenantBroker.applyAs(tenantId, tid -> doProcess(taskId)); + return r == null ? -1 : r; } @Override @@ -433,16 +437,25 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic return n; } - /** @return true 成功或跳过;false 失败/待重试 */ - private boolean doProcess(Long taskId) { + /** @return 1 成功,0 跳过,-1 失败 */ + private int doProcess(Long taskId) { SmHotelCallbackTaskEntity task = taskMapper.selectById(taskId); + // 已从 List 弹出,去掉 queued 标记,失败重试才能再次入队 + unmarkQueued(taskId); if (task == null) { log.warn("|回调队列|任务不存在|taskId={}|", taskId); - return true; + return 0; } if (!STATUS_PENDING.equals(task.getStatus())) { - log.info("|回调队列|跳过非 PENDING|taskId={}|status={}|", taskId, task.getStatus()); - return true; + skipNonPendingCount++; + if (skipNonPendingCount == 1 || skipNonPendingCount % 200 == 0) { + log.info("|回调队列|跳过非 PENDING(Redis 残留已完成副本)|taskId={}|status={}|skipped={}|", + taskId, task.getStatus(), skipNonPendingCount); + } + else { + log.debug("|回调队列|跳过非 PENDING|taskId={}|status={}|", taskId, task.getStatus()); + } + return 0; } int attempts = task.getAttemptCount() == null ? 0 : task.getAttemptCount(); int max = Math.max(1, props().getCallbackMaxAttempts()); @@ -460,7 +473,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic : JSONUtil.parseObj(task.getReqJson()); Map result = orderService.handleOrderStatusNotify(body); finish(task, STATUS_SUCCESS, "订单状态更新: " + JSONUtil.toJsonStr(result), t0); - return true; + return 1; } if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) { SmHotelEntity hotel = resolveWritableHotelByCode(task.getHotelCode(), SmHotelDataSource.SHANGYOU); @@ -472,7 +485,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic } if (task.getHotelId() == null) { finish(task, STATUS_SKIPPED, "无商游可写酒店(hotelCode=" + task.getHotelCode() + "),本地或其它渠道不会覆盖", t0); - return true; + return 1; } // 消费前把同酒店其余待处理房价/房量并进本条,积压的重复通知不再各拉一次 int folded = foldPendingSiblings(task); @@ -487,7 +500,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic SmHotelDataSource.denyWriteMessage(bound == null ? null : bound.getSheetName(), SmHotelDataSource.SHANGYOU), t0); - return true; + return 1; } // 回调只拉通知日期区间,避免每次全量 90 天把单线程队列堵死 R> pull = pullForCallback(task); @@ -495,11 +508,11 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic Object data = pull.getData(); finish(task, STATUS_SUCCESS, "拉取成功" + (data == null ? "" : ": " + StrUtil.maxLength(JSONUtil.toJsonStr(data), 800)), t0); - return true; + return 1; } String msg = pull == null ? "拉取返回空" : StrUtil.blankToDefault(pull.getMsg(), "拉取失败"); handleFailOrRetry(task, msg, t0, max); - return false; + return -1; } catch (Exception e) { String msg = StrUtil.maxLength(e.getMessage(), 800); @@ -511,7 +524,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic log.error("|回调队列|处理异常|taskId={}|", taskId, e); } handleFailOrRetry(task, msg, t0, max); - return false; + return -1; } } @@ -616,6 +629,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic task.setFinishTime(LocalDateTime.now()); task.setUpdateTime(LocalDateTime.now()); taskMapper.updateById(task); + unmarkQueued(task.getId()); log.info("|回调队列|完成|taskId={}|status={}|costMs={}|msg={}|", task.getId(), status, task.getCostMs(), task.getResultMsg()); } @@ -765,7 +779,14 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic /** 订单状态回调最高优先:进高优队列,Worker 始终先于房价/房量消费 */ private void enqueueTaskId(Long taskId, String notifyType) { + if (taskId == null) { + return; + } String id = String.valueOf(taskId); + Long added = redisTemplate.opsForSet().add(ShangyouHotelApi.REDIS_CALLBACK_QUEUED, id); + if (added != null && added == 0L) { + return; + } if (isHighPriority(notifyType)) { redisTemplate.opsForList().leftPush(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH, id); } @@ -774,6 +795,18 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic } } + private void unmarkQueued(Long taskId) { + if (taskId == null) { + return; + } + try { + redisTemplate.opsForSet().remove(ShangyouHotelApi.REDIS_CALLBACK_QUEUED, String.valueOf(taskId)); + } + catch (Exception e) { + log.debug("|回调队列|清除 queued 标记失败|taskId={}|{}", taskId, e.getMessage()); + } + } + private static boolean isHighPriority(String notifyType) { return "ORDER_STATUS".equalsIgnoreCase(notifyType); }