This commit is contained in:
dzc committed 2026-10-23 09:43:55 +08:00
commit 2caa7dbff4
6 files changed
+532 -129

No files matched your search

@@ -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<String, Object> buildFilterMeta() {
Set<String> 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<String> 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<String> 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<Map<String, Object>> starLevels = new ArrayList<>();
STAR_LABEL.forEach((k, v) -> {
Map<String, Object> 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<String, Object> 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<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));
}
}
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);
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<Long> 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<Long> 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<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 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 List.of();
}
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) {
byId.put(h.getId(), h);
}
}
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 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<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()));
putHotelMinPrices(out, productMapper.selectMinSellPriceByHotelIds(part));
}
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"))));
}
}
/**
* 起价筛选等价于 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));
@@ -349,35 +424,100 @@ public class SmHotelAppController {
return out;
}
/**
* 按酒店名搜索:
* 1) 前缀匹配(idx_hotel_name + ORDER BY,快);
* 2) 无结果再含匹配——SQL 不做 ORDER BY,只 LIMIT,再内存排序(避免超时变空列表)。
* 有城市时尽量落到 district_adcode 区间。
*/
private Page<SmHotelEntity> pageByHotelNameKeyword(Page<SmHotelEntity> page, String keyword, String city,
String district, Long districtAdcode, List<Integer> 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<Integer> stars = starList == null || starList.isEmpty() ? null : starList;
List<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> out = new Page<>(current, size, total);
out.setRecords(new ArrayList<>(slice));
return out;
}
/** 上架 + 区域/关键字/星级等公共条件(不含可售日历、排序) */
private LambdaQueryWrapper<SmHotelEntity> baseOnlineListWrapper(String keyword, String city, String district,
Long districtAdcode, String bizArea, List<Integer> starList, BigDecimal minScore,
List<String> facilityList) {
return baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore,
facilityList, true);
}
/**
* @param namePrefix true=酒店名走前缀(可用 idx_hotel_name);false=含匹配(仅前缀无结果时)
*/
private LambdaQueryWrapper<SmHotelEntity> baseOnlineListWrapper(String keyword, String city, String district,
Long districtAdcode, String bizArea, List<Integer> starList, BigDecimal minScore,
List<String> facilityList, boolean namePrefix) {
var wrapper = Wrappers.<SmHotelEntity>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<SmHotelEntity> raw, String placeName, String keyword, String bizArea,
List<Integer> starList, BigDecimal minScore, List<String> 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<Long> 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.<SmHotelEntity>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<SmHotelEntity> extra = hotelService.list(w);
if (extra == null || extra.isEmpty()) {
return;
}
List<SmHotelEntity> 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<SmHotelEntity> 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<SmHotelEntity> rows = hotelService.list(Wrappers.<SmHotelEntity>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<SmHotelEntity> rows, boolean promoteToCity) {
if (rows == null || rows.isEmpty()) {
return null;
}
Map<Long, Integer> 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)。
*/
@@ -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 {
@@ -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";
@@ -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<SmHotelEntity> {
int insertBatch(@Param("list") List<SmHotelEntity> list);
/**
* App 店名前缀搜索:走 idx_hotel_name,可带区划区间;带 ORDER BY 安全(前缀命中集小)。
*/
@Select("""
<script>
SELECT id, hotel_name AS hotelName, hotel_code AS hotelCode, address, district,
district_adcode AS districtAdcode, biz_area AS bizArea, star_level AS starLevel,
score, facility_tags AS facilityTags, cover_image AS coverImage, phone,
sheet_name AS sheetName, upstream_status AS upstreamStatus, update_time AS updateTime
FROM canteen_hotel FORCE INDEX (idx_hotel_name)
WHERE del_flag = '0'
AND (upstream_status IS NULL OR upstream_status &lt;&gt; 'OFFLINE')
AND (hotel_name LIKE CONCAT(#{keyword}, '%') OR hotel_code = #{keyword})
<if test="adFrom != null">
AND district_adcode &gt;= #{adFrom} AND district_adcode &lt; #{adTo}
</if>
<if test="starLevels != null and starLevels.size() &gt; 0">
AND star_level IN
<foreach collection="starLevels" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
ORDER BY IFNULL(star_level, 0) DESC, id DESC
LIMIT #{limit}
</script>
""")
List<SmHotelEntity> selectAppByNamePrefix(@Param("keyword") String keyword, @Param("adFrom") Long adFrom,
@Param("adTo") Long adTo, @Param("starLevels") Collection<Integer> starLevels,
@Param("limit") int limit);
/**
* App 店名包含搜索:禁止 ORDER BY(含匹配 + 排序 = 全表 filesort 超时)。
* 先 LIMIT 取候选,由调用方内存排序。有 adcode 时扫描范围极小。
*/
@Select("""
<script>
SELECT id, hotel_name AS hotelName, hotel_code AS hotelCode, address, district,
district_adcode AS districtAdcode, biz_area AS bizArea, star_level AS starLevel,
score, facility_tags AS facilityTags, cover_image AS coverImage, phone,
sheet_name AS sheetName, upstream_status AS upstreamStatus, update_time AS updateTime
FROM canteen_hotel
WHERE del_flag = '0'
AND (upstream_status IS NULL OR upstream_status &lt;&gt; 'OFFLINE')
AND hotel_name LIKE CONCAT('%', #{keyword}, '%')
<if test="adFrom != null">
AND district_adcode &gt;= #{adFrom} AND district_adcode &lt; #{adTo}
</if>
<if test="starLevels != null and starLevels.size() &gt; 0">
AND star_level IN
<foreach collection="starLevels" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
LIMIT #{limit}
</script>
""")
List<SmHotelEntity> selectAppByNameContains(@Param("keyword") String keyword, @Param("adFrom") Long adFrom,
@Param("adTo") Long adTo, @Param("starLevels") Collection<Integer> starLevels,
@Param("limit") int limit);
}
@@ -50,8 +50,11 @@ public interface SmHotelCallbackTaskService {
/** 清空全部回调任务,并清空 Redis 消费队列 */
R<Integer> clearAllTasks();
/** 消费一条队列任务(Worker 调用);返回 true=本次成功/跳过,false=失败或待再试 */
boolean processQueuedTask(Long taskId);
/**
* 消费一条队列任务(Worker 调用)。
* @return 1 已处理成功,0 跳过(非 PENDING / 不存在),-1 失败待重试
*/
int processQueuedTask(Long taskId);
/** 启动/人工:把全部 PENDING + 超时 RUNNING 重新入队 */
int requeueStalePending();
@@ -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<String, Object> 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<Map<String, Object>> 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);
}