diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java index 9c4222019..c9473470e 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelDownstreamChannelConfigDTO.java @@ -56,6 +56,9 @@ public class SmHotelDownstreamChannelConfigDTO { @Schema(description = "product/daily 每分钟上限,默认 300;≤0 关闭") private Integer rateLimitProductDailyPerMin; + @Schema(description = "慧订 hoteldatepush 出站 QPS,默认 5;≤0 关闭") + private Integer rateLimitHotelDatePushQps; + @Schema(description = "order/query 每分钟上限,默认 120;≤0 关闭") private Integer rateLimitOrderQueryPerMin; diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java index 2b124c5bf..acdb8ed4b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelOrderEntity.java @@ -129,11 +129,16 @@ public class SmHotelOrderEntity extends Model { @Schema(description = "酒店名称") private String hotelName; - /** 详情展示:产品名称(非表字段) */ + /** 列表/详情展示:产品名称(非表字段) */ @TableField(exist = false) @Schema(description = "产品名称") private String productName; + /** 列表展示:房型名称(非表字段) */ + @TableField(exist = false) + @Schema(description = "房型名称") + private String roomTypeName; + /** 列表展示:下单来源 APP/ADMIN/LOCAL/DOWNSTREAM(非表字段) */ @TableField(exist = false) @Schema(description = "下单来源") diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java index c6d5854b4..42bff3796 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelProductEntity.java @@ -77,4 +77,9 @@ public class SmHotelProductEntity extends Model { @TableLogic @TableField(fill = FieldFill.INSERT) private String delFlag; + + /** App 详情:所选入住区间无日历/不可订时为 true(非表字段) */ + @TableField(exist = false) + @Schema(description = "所选日期已售罄") + private Boolean soldOut; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java index 430d54cd1..dd226db43 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/HotelDownstreamProperties.java @@ -70,6 +70,12 @@ public class HotelDownstreamProperties { /** /product/sync、/daily/sync 每分钟上限(环启文档 300) */ private int rateLimitProductDailyPerMin = 300; + /** + * 慧订 /splider/hoteldatepush 出站 QPS(对方已限流)。 + * 默认 5;≤0 关闭自我节流。多实例请按实例均分,例如 2 实例各配 2~3。 + */ + private int rateLimitHotelDatePushQps = 5; + /** /order/query 每分钟上限(环启文档 120) */ private int rateLimitOrderQueryPerMin = 120; 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 0d3f33a6e..677995d95 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 @@ -87,7 +87,7 @@ public class SmHotelAppController { private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW"; /** 列表页结果短缓存(同条件重复进入/下拉) */ - private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v5:"; + private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:v6:"; private static final long PAGE_CACHE_SECONDS = 45L; @@ -271,18 +271,23 @@ public class SmHotelAppController { int candSize = -1; boolean smallScope = scopedIds != null && !scopedIds.isEmpty() && scopedIds.size() <= MAX_SCOPED_HOTEL_IDS; if (smallScope) { - List candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap, scopedIds); - candSize = candidateIds == null ? -1 : candidateIds.size(); - if (candidateIds != null && candidateIds.isEmpty()) { - return R.ok(emptyPage(page)); - } - if (candidateIds != null) { - wrapper.in(SmHotelEntity::getId, candidateIds); + List candidateIds = scopedIds; + if (needCap > 1) { + List capHotels = roomMapper.selectDistinctHotelIdsByMinCapacity(needCap); + if (capHotels == null || capHotels.isEmpty()) { + return R.ok(emptyPage(page)); + } + Set capSet = new java.util.HashSet<>(capHotels); + candidateIds = scopedIds.stream().filter(capSet::contains).toList(); + if (candidateIds.isEmpty()) { + return R.ok(emptyPage(page)); + } } + candSize = candidateIds.size(); + wrapper.in(SmHotelEntity::getId, candidateIds); } else { - // 全国:按评分分页 + 每家酒店 EXISTS 可订,不做扫描上限(可订的都会出现,只是排序靠后) - applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive); + // 全国:上架酒店都出列表;所选日期无日历的在行上标已售罄,不再按可订日历过滤掉 applyGuestCapacityFilter(wrapper, needCap); } if (minPrice != null || maxPrice != null) { @@ -297,7 +302,12 @@ public class SmHotelAppController { .orderByDesc(SmHotelEntity::getId); raw = pageWithoutCount(page, wrapper); List ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); - Map minPriceMap = loadMinSellPrice(ids); + LocalDate priceFrom = stayFrom != null ? stayFrom : LocalDate.now(); + LocalDate priceToEx = stayToExclusive; + if (priceToEx == null || !priceToEx.isAfter(priceFrom)) { + priceToEx = priceFrom.plusDays(1); + } + Map minPriceMap = loadMinSellPrice(ids, priceFrom, priceToEx); Page> out = new Page<>(raw.getCurrent(), raw.getSize(), raw.getTotal()); out.setRecords(raw.getRecords().stream() .filter(Objects::nonNull) @@ -454,7 +464,10 @@ public class SmHotelAppController { @Operation(summary = "酒店详情(含房型/产品)") @GetMapping("/detail/{id}") - public R detail(@PathVariable Long id) { + public R detail(@PathVariable Long id, + @RequestParam(required = false) String checkIn, + @RequestParam(required = false) String checkOut, + @RequestParam(required = false) Integer rooms) { R r = midService.getHotelDetail(id); if (r.getCode() != 0 || r.getData() == null || r.getData().getHotel() == null) { return r; @@ -462,31 +475,56 @@ public class SmHotelAppController { if ("OFFLINE".equalsIgnoreCase(r.getData().getHotel().getUpstreamStatus())) { return R.failed("酒店已下架"); } - // App 侧:没有未来日历的产品无法预订,不展示 - hideProductsWithoutCalendar(r.getData()); + // 无日历的产品仍展示,所选入住日标已售罄 + markProductSoldOut(r.getData(), parseDay(checkIn), parseDay(checkOut), rooms); return r; } /** - * 过滤无未来日历的产品。无日历则无法计价/占库存,列表展示无意义。 + * 按入住半开区间给产品打已售罄:缺日历、关房或剩余库存不足。 */ - private void hideProductsWithoutCalendar(SmHotelDetailVO vo) { + private void markProductSoldOut(SmHotelDetailVO vo, LocalDate checkIn, LocalDate checkOut, Integer rooms) { if (vo == null || vo.getHotel() == null || vo.getHotel().getId() == null || vo.getProducts() == null || vo.getProducts().isEmpty()) { return; } - List rows = Db.lambdaQuery(SmHotelDailyEntity.class) + LocalDate from = checkIn != null ? checkIn : LocalDate.now(); + LocalDate toEx = checkOut; + if (toEx == null || !toEx.isAfter(from)) { + toEx = from.plusDays(1); + } + int need = rooms == null || rooms < 1 ? 1 : Math.min(rooms, 20); + List nights = new ArrayList<>(); + for (LocalDate d = from; d.isBefore(toEx); d = d.plusDays(1)) { + nights.add(d); + } + List rows = dailyMapper.selectList(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, vo.getHotel().getId()) - .ge(SmHotelDailyEntity::getStayDate, LocalDate.now()) - .select(SmHotelDailyEntity::getProductCode) - .list(); - Set withDaily = rows.stream() - .map(SmHotelDailyEntity::getProductCode) - .filter(StrUtil::isNotBlank) - .collect(Collectors.toCollection(LinkedHashSet::new)); - vo.setProducts(vo.getProducts().stream() - .filter(p -> p != null && withDaily.contains(p.getProductCode())) - .collect(Collectors.toList())); + .ge(SmHotelDailyEntity::getStayDate, from) + .lt(SmHotelDailyEntity::getStayDate, toEx)); + Map> bookable = new HashMap<>(); + if (rows != null) { + for (SmHotelDailyEntity row : rows) { + if (row == null || StrUtil.isBlank(row.getProductCode()) || row.getStayDate() == null) { + continue; + } + int status = row.getDailyStatus() == null ? 1 : row.getDailyStatus(); + int stock = row.getStock() == null ? 0 : row.getStock(); + int sold = row.getSold() == null ? 0 : row.getSold(); + if (status != 1 || stock - sold < need) { + continue; + } + bookable.computeIfAbsent(row.getProductCode(), k -> new java.util.HashSet<>()).add(row.getStayDate()); + } + } + for (SmHotelProductEntity p : vo.getProducts()) { + if (p == null) { + continue; + } + Set ok = bookable.getOrDefault(p.getProductCode(), Set.of()); + boolean soldOut = nights.isEmpty() || nights.stream().anyMatch(d -> !ok.contains(d)); + p.setSoldOut(soldOut); + } } @Operation(summary = "酒店日历分页") @@ -698,15 +736,17 @@ public class SmHotelAppController { row.put("phone", h.getPhone()); row.put("sheetName", h.getSheetName()); row.put("minSellPrice", minSellPrice); + row.put("soldOut", minSellPrice == null); return row; } - private Map loadMinSellPrice(List hotelIds) { + /** 所选入住区间可订日历的最低卖价;没有可订日历则不返回,列表标已售罄 */ + private Map loadMinSellPrice(List hotelIds, LocalDate from, LocalDate toExclusive) { Map map = new HashMap<>(); - if (hotelIds == null || hotelIds.isEmpty()) { + if (hotelIds == null || hotelIds.isEmpty() || from == null || toExclusive == null) { return map; } - List> rows = productMapper.selectMinSellPriceByHotelIds(hotelIds); + List> rows = dailyMapper.selectMinSellPriceByStayRange(from, toExclusive, hotelIds); if (rows == null) { return map; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java index 624a7a77d..34cae5067 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java @@ -14,8 +14,11 @@ import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogConte import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + /** * 慧订下游:静态 pushStaticHotels + 价态 hoteldatepush(窗口全量覆盖)。 * 请求体带 key / supplierId,成功码为 0(与环启 200 不同)。 @@ -27,8 +30,15 @@ public class HuidingDownstreamHotelAdapter implements DownstreamHotelAdapter { private static final String TAG = "下游推送[慧订]"; + private static final String RATE_KEY = "sm:hotel:huiding:qps:"; + private final SmHotelDownstreamPushLogService pushLogService; + private final StringRedisTemplate redisTemplate; + + /** 本进程 hoteldatepush 下次可发时间 */ + private volatile long datePushNextPermitAtMs = 0L; + @Override public String protocol() { return SmHotelDownstreamProtocol.HUIDING; @@ -106,6 +116,30 @@ public class HuidingDownstreamHotelAdapter implements DownstreamHotelAdapter { String action, String path, Object bodyObj) { assertReady(partnerCode, props); String bodyJson = bodyObj instanceof String ? (String) bodyObj : JSONUtil.toJsonStr(bodyObj); + int maxRetry = Math.max(0, props.getRateLimitMaxRetries()); + long backoff = Math.max(1000L, props.getRateLimitBackoffMs()); + CheckedException last = null; + for (int attempt = 0; attempt <= maxRetry; attempt++) { + acquireHotelDatePushPermit(partnerCode, props, path); + try { + return doPostOnce(partnerCode, props, hotelId, extHotelId, action, path, bodyJson); + } + catch (CheckedException e) { + last = e; + if (!isRateLimitedMsg(e.getMessage()) || attempt >= maxRetry) { + throw e; + } + long wait = backoff * (1L << attempt); + log.warn("|{}|限流退避|partner={}|path={}|attempt={}|waitMs={}|", TAG, partnerCode, path, attempt + 1, + wait); + sleepQuiet(wait); + } + } + throw last != null ? last : new CheckedException("慧订调用失败"); + } + + private JSONObject doPostOnce(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + String action, String path, String bodyJson) { String url = trimSlash(props.getBaseUrl()) + path; long t0 = System.currentTimeMillis(); Integer httpStatus = null; @@ -139,7 +173,10 @@ public class HuidingDownstreamHotelAdapter implements DownstreamHotelAdapter { StrUtil.maxLength(trimmed, 180)); } } - if (json != null) { + if (httpStatus != null && httpStatus == 429) { + errMsg = StrUtil.format("慧订限流 HTTP 429 [{}] body={}", path, StrUtil.maxLength(trimmed, 180)); + } + else if (json != null) { // 慧订:code=0 成功;兼容部分接口用 200 int bizCode = json.getInt("code", -1); if (bizCode == 0 || bizCode == 200) { @@ -214,6 +251,69 @@ public class HuidingDownstreamHotelAdapter implements DownstreamHotelAdapter { } } + /** + * hoteldatepush 出站 QPS:本进程匀速 + Redis 秒窗(多实例共享)。 + * 配置 rate-limit-hotel-date-push-qps ≤0 关闭。 + */ + private void acquireHotelDatePushPermit(String partnerCode, HotelDownstreamProperties props, String path) { + if (!isHotelDatePushPath(path)) { + return; + } + int qps = props.getRateLimitHotelDatePushQps(); + if (qps <= 0) { + return; + } + long intervalMs = Math.max(1L, Math.round(1000.0 / qps)); + synchronized (this) { + long now = System.currentTimeMillis(); + if (datePushNextPermitAtMs > now) { + sleepQuiet(datePushNextPermitAtMs - now); + now = System.currentTimeMillis(); + } + datePushNextPermitAtMs = Math.max(now, datePushNextPermitAtMs) + intervalMs; + } + int maxPerSec = Math.max(1, qps); + String partner = StrUtil.blankToDefault(partnerCode, "default"); + for (int i = 0; i < 30; i++) { + long sec = System.currentTimeMillis() / 1000L; + String key = RATE_KEY + partner + ":" + sec; + Long n = redisTemplate.opsForValue().increment(key); + if (n != null && n == 1L) { + redisTemplate.expire(key, 3, TimeUnit.SECONDS); + } + if (n == null || n <= maxPerSec) { + return; + } + long wait = 1000L - (System.currentTimeMillis() % 1000L) + 5L; + sleepQuiet(wait); + } + log.warn("|{}|hoteldatepush QPS 等待超时仍继续|partner={}|qps={}|", TAG, partner, qps); + } + + private static boolean isHotelDatePushPath(String path) { + return StrUtil.isNotBlank(path) && path.contains("hoteldatepush"); + } + + private static boolean isRateLimitedMsg(String msg) { + if (StrUtil.isBlank(msg)) { + return false; + } + return msg.contains("过于频繁") || msg.contains("限流") || msg.contains("429") + || msg.toLowerCase().contains("too many") || msg.toLowerCase().contains("rate limit"); + } + + private static void sleepQuiet(long ms) { + if (ms <= 0) { + return; + } + try { + Thread.sleep(ms); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + public static String resolveKey(HotelDownstreamProperties props) { if (props == null) { return ""; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java index 8bf12f55f..752c8456d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java @@ -58,6 +58,27 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper selectDistinctHotelIdsByStayRangeInHotels(@Param("fromDate") LocalDate fromDate, @Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection hotelIds); + /** + * 入住半开区间内可订日历的最低卖价(开房且有剩余库存)。 + */ + @Select(""" + + """) + List> selectMinSellPriceByStayRange(@Param("fromDate") LocalDate fromDate, + @Param("toExclusive") LocalDate toExclusive, @Param("hotelIds") Collection hotelIds); + int insertBatch(@Param("list") List list); /** diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java index 146a05ab9..1a968430b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelChannelConfigServiceImpl.java @@ -344,6 +344,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl records) { @@ -290,6 +290,51 @@ public class SmHotelOrderServiceImpl extends ServiceImpl productByHotelCode = new HashMap<>(); + Map productByCode = new HashMap<>(); + List productCodes = records.stream() + .map(SmHotelOrderEntity::getProductCode) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.toList()); + if (!productCodes.isEmpty()) { + var productQ = Wrappers.lambdaQuery() + .in(SmHotelProductEntity::getProductCode, productCodes); + if (!hotelIds.isEmpty()) { + productQ.in(SmHotelProductEntity::getHotelId, hotelIds); + } + for (SmHotelProductEntity p : productMapper.selectList(productQ)) { + if (StrUtil.isNotBlank(p.getProductCode())) { + productByCode.putIfAbsent(p.getProductCode(), p); + if (p.getHotelId() != null) { + productByHotelCode.put(p.getHotelId() + "|" + p.getProductCode(), p); + } + } + } + } + Map roomByHotelCode = new HashMap<>(); + List roomTypeCodes = new ArrayList<>(records.stream() + .map(SmHotelOrderEntity::getRoomTypeCode) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.toList())); + for (SmHotelProductEntity p : productByCode.values()) { + if (StrUtil.isNotBlank(p.getRoomTypeCode()) && !roomTypeCodes.contains(p.getRoomTypeCode())) { + roomTypeCodes.add(p.getRoomTypeCode()); + } + } + if (!roomTypeCodes.isEmpty()) { + var roomQ = Wrappers.lambdaQuery() + .in(SmHotelRoomEntity::getRoomTypeCode, roomTypeCodes); + if (!hotelIds.isEmpty()) { + roomQ.in(SmHotelRoomEntity::getHotelId, hotelIds); + } + for (SmHotelRoomEntity r : roomMapper.selectList(roomQ)) { + if (r.getHotelId() != null && StrUtil.isNotBlank(r.getRoomTypeCode())) { + roomByHotelCode.putIfAbsent(r.getHotelId() + "|" + r.getRoomTypeCode(), r); + } + } + } for (SmHotelOrderEntity o : records) { SmHotelEntity hotel = o.getHotelId() != null ? hotelById.get(o.getHotelId()) : null; if (hotel == null && StrUtil.isNotBlank(o.getHotelCode())) { @@ -299,6 +344,28 @@ public class SmHotelOrderServiceImpl extends ServiceImpl cancelR = cancelUpstreamOrder(order, reason); if (cancelR == null || !cancelR.isOk()) { return R.failed(cancelR == null ? "取消失败" : cancelR.getMsg()); } + notifyOpenOrderCallback(baseMapper.selectById(order.getId())); return R.ok(true); } @@ -2413,6 +2484,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl