From 04a39fbeff0c9b76e7303a7f3b336e0c07370d37 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Wed, 21 Oct 2026 13:14:48 +0800 Subject: [PATCH] =?UTF-8?q?=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supermarket/constant/SmHotelOpenApi.java | 13 + .../config/SmHotelOpenProperties.java | 23 + .../controller/SmHotelAppController.java | 270 +++++++++--- .../controller/SmHotelOpenApiController.java | 16 + .../shangyou/ShangyouHotelClient.java | 58 ++- .../mapper/SmHotelDailyMapper.java | 16 + .../service/SmHotelOpenApiService.java | 6 + .../SmHotelOpenResourceNotifyService.java | 21 + .../service/impl/SmHotelMidServiceImpl.java | 11 +- .../impl/SmHotelOpenApiServiceImpl.java | 411 ++++++++++++++++-- .../SmHotelOpenResourceNotifyServiceImpl.java | 112 +++++ .../service/impl/SmHotelOrderServiceImpl.java | 20 +- .../service/impl/SmHotelServiceImpl.java | 27 +- .../src/main/resources/application.yml | 7 + 14 files changed, 910 insertions(+), 101 deletions(-) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SmHotelOpenProperties.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenResourceNotifyService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenResourceNotifyServiceImpl.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java index 3c3473637..268b3de75 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelOpenApi.java @@ -13,6 +13,15 @@ public final class SmHotelOpenApi { public static final String HOTEL_DAILY = BASE + "/hotel/daily"; + /** 授权范围内筛选项(区域 adcode/名称、商圈、星级等) */ + public static final String HOTEL_FILTER_META = BASE + "/hotel/filter-meta"; + + /** + * 增量同步拉取(下游落库用):按 updateTime 水位翻页,可含下架。 + * 路径仍走 list 增强参数亦可;独立路径便于限流与文档。 + */ + public static final String HOTEL_SYNC = BASE + "/hotel/sync"; + public static final String ORDER_CHECK = BASE + "/order/check"; public static final String ORDER_CREATE = BASE + "/order/create"; @@ -28,6 +37,10 @@ public final class SmHotelOpenApi { public static final String API_HOTEL_DAILY = "hotel.daily"; + public static final String API_HOTEL_FILTER_META = "hotel.filterMeta"; + + public static final String API_HOTEL_SYNC = "hotel.sync"; + public static final String API_ORDER_CHECK = "order.check"; public static final String API_ORDER_CREATE = "order.create"; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SmHotelOpenProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SmHotelOpenProperties.java new file mode 100644 index 000000000..f19d87e68 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/SmHotelOpenProperties.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.supermarket.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 酒店下游开放平台配置。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "sm-hotel.open") +public class SmHotelOpenProperties { + + /** + * 对外开放的文件/图片根地址(含网关前缀,无尾斜杠),例如: + * https://www.zianxin.com/api + *

+ * 库内相对路径(如 /admin/sys-file/oss/file?fileName=xx)会拼到此前缀后下发; + * 已是 http(s) 的上游图原样返回。未配置时仍返回库内原值(下游可能无法展示)。 + */ + private String publicFileBaseUrl = ""; +} 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 63b5127a5..793c30da8 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 @@ -86,6 +86,14 @@ public class SmHotelAppController { private static final String CANDIDATE_CACHE_OVERFLOW = "OVERFLOW"; + /** 列表页结果短缓存(同条件重复进入/下拉) */ + private static final String PAGE_CACHE_PREFIX = "sm:hotel:app:page:"; + + private static final long PAGE_CACHE_SECONDS = 45L; + + /** 先按区域/关键字缩小酒店集合时的上限,超出则回退 EXISTS */ + private static final int MAX_SCOPED_HOTEL_IDS = 3000; + private final SmHotelService hotelService; private final SmHotelMidService midService; @@ -219,8 +227,95 @@ public class SmHotelAppController { @RequestParam(required = false) Integer guests, @RequestParam(required = false) String checkIn, @RequestParam(required = false) String checkOut) { + long t0 = System.currentTimeMillis(); List starList = parseIntCsv(starLevels); List facilityList = parseStrCsv(facilityTags); + LocalDate stayFrom = parseDay(checkIn); + LocalDate stayToExclusive = parseDay(checkOut); + int guestTotal = resolveGuestTotal(guests, adults, children); + int roomCnt = rooms == null || rooms < 1 ? 1 : Math.min(rooms, 20); + int needCap = guestTotal > 0 ? Math.max(1, (guestTotal + roomCnt - 1) / roomCnt) : 1; + + String pageCacheKey = buildPageCacheKey(page.getCurrent(), page.getSize(), keyword, city, district, + districtAdcode, bizArea, starLevels, minScore, minPrice, maxPrice, facilityTags, rooms, adults, + children, guests, checkIn, checkOut); + Page> cached = readPageCache(pageCacheKey); + if (cached != null) { + log.info("|酒店App|page命中缓存|current={}|size={}|costMs={}|", page.getCurrent(), page.getSize(), + System.currentTimeMillis() - t0); + 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)); + } + } + + // 有城市/关键字时:先在缩小后的酒店集合内查日历,避免全库 DISTINCT + List candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap, scopedIds); + if (candidateIds != null) { + if (candidateIds.isEmpty()) { + return R.ok(emptyPage(page)); + } + wrapper.in(SmHotelEntity::getId, candidateIds); + } + else { + // 候选过多或未预筛:EXISTS / IN(子查询),配合 idx_daily_hotel_stay + applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive); + applyGuestCapacityFilter(wrapper, needCap); + } + if (minPrice != null || maxPrice != null) { + List priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice); + if (priceHitIds.isEmpty()) { + return R.ok(emptyPage(page)); + } + wrapper.in(SmHotelEntity::getId, priceHitIds); + } + wrapper.orderByDesc(SmHotelEntity::getScore) + .orderByDesc(SmHotelEntity::getUpdateTime) + .orderByDesc(SmHotelEntity::getId); + + Page raw = hotelService.page(page, wrapper); + List ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); + Map minPriceMap = loadMinSellPrice(ids); + Page> 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()); + writePageCache(pageCacheKey, out); + log.info("|酒店App|page|current={}|size={}|total={}|scope={}|cand={}|costMs={}|", page.getCurrent(), + page.getSize(), raw.getTotal(), scopedIds == null ? -1 : scopedIds.size(), + candidateIds == null ? -1 : candidateIds.size(), System.currentTimeMillis() - t0); + return R.ok(out); + } + + private static Page> emptyPage(Page page) { + Page> empty = new Page<>(page.getCurrent(), page.getSize(), 0); + empty.setRecords(List.of()); + return empty; + } + + /** 上架 + 区域/关键字/星级等公共条件(不含可售日历、排序) */ + private LambdaQueryWrapper baseOnlineListWrapper(String keyword, String city, String district, + Long districtAdcode, String bizArea, List starList, BigDecimal minScore, + List facilityList) { var wrapper = Wrappers.lambdaQuery() .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() @@ -234,12 +329,12 @@ public class SmHotelAppController { .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())); - // 有 adcode:数值区间走索引 + 名称兜底;仅名称:保持精确匹配(与历史 App 行为一致) + .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); } @@ -248,53 +343,30 @@ public class SmHotelAppController { } wrapper.like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, StrUtil.trim(bizArea)) .in(!starList.isEmpty(), SmHotelEntity::getStarLevel, starList) - .ge(minScore != null, SmHotelEntity::getScore, minScore) - .orderByDesc(SmHotelEntity::getScore) - .orderByDesc(SmHotelEntity::getUpdateTime) - .orderByDesc(SmHotelEntity::getId); + .ge(minScore != null, SmHotelEntity::getScore, minScore); for (String tag : facilityList) { wrapper.like(SmHotelEntity::getFacilityTags, tag); } - LocalDate stayFrom = parseDay(checkIn); - LocalDate stayToExclusive = parseDay(checkOut); - int guestTotal = resolveGuestTotal(guests, adults, children); - int roomCnt = rooms == null || rooms < 1 ? 1 : Math.min(rooms, 20); - int needCap = guestTotal > 0 ? Math.max(1, (guestTotal + roomCnt - 1) / roomCnt) : 1; - // 先查候选酒店 id(DISTINCT),再 IN;避免对大表相关 EXISTS + ORDER BY 拖慢分页 - List candidateIds = resolveAppListCandidateHotelIds(stayFrom, stayToExclusive, needCap); - if (candidateIds != null) { - if (candidateIds.isEmpty()) { - Page> empty = new Page<>(page.getCurrent(), page.getSize(), 0); - empty.setRecords(List.of()); - return R.ok(empty); - } - wrapper.in(SmHotelEntity::getId, candidateIds); + return wrapper; + } + + /** + * 先按区域/关键字等缩小酒店 id;过大则返回 null(调用方走 EXISTS,避免超长 IN)。 + */ + private List loadScopedHotelIds(String keyword, String city, String district, Long districtAdcode, + String bizArea, List starList, BigDecimal minScore, List facilityList) { + var w = baseOnlineListWrapper(keyword, city, district, districtAdcode, bizArea, starList, minScore, + facilityList); + w.select(SmHotelEntity::getId); + w.last("LIMIT " + (MAX_SCOPED_HOTEL_IDS + 1)); + List rows = hotelService.list(w); + if (rows == null || rows.isEmpty()) { + return List.of(); } - else { - // 候选过多:用 IN(子查询) 替代相关 EXISTS,语义不变、更易走 stay_date 索引 - applyBookableHotelFilter(wrapper, stayFrom, stayToExclusive); - applyGuestCapacityFilter(wrapper, needCap); + if (rows.size() > MAX_SCOPED_HOTEL_IDS) { + return null; } - // 起价区间:按产品最低卖价过滤(分页前约束) - if (minPrice != null || maxPrice != null) { - List priceHitIds = loadHotelIdsByMinPriceRange(minPrice, maxPrice); - if (priceHitIds.isEmpty()) { - Page> empty = new Page<>(page.getCurrent(), page.getSize(), 0); - empty.setRecords(List.of()); - return R.ok(empty); - } - wrapper.in(SmHotelEntity::getId, priceHitIds); - } - // 移动端 TOC 不走管理端数据权限,按租户查已上架酒店 - Page raw = hotelService.page(page, wrapper); - List ids = raw.getRecords().stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); - Map minPriceMap = loadMinSellPrice(ids); - Page> 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()); - return R.ok(out); + return rows.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).distinct().toList(); } @Operation(summary = "读取当前用户酒店搜索习惯") @@ -632,16 +704,27 @@ public class SmHotelAppController { * 解析列表候选酒店: * 1) 入住区间内有日历; * 2) 可选:房型可住人数达标。 - * 返回 null 表示候选过多,调用方回退 IN(子查询)。 + * @param scopedHotelIds 区域/关键字等已缩小的酒店集合;null=全国未缩小(不预加载全库 DISTINCT,回退 EXISTS) + * @return null 表示调用方应回退 EXISTS / IN(子查询) */ - private List resolveAppListCandidateHotelIds(LocalDate stayFrom, LocalDate stayToExclusive, int needCapPerRoom) { + private List resolveAppListCandidateHotelIds(LocalDate stayFrom, LocalDate stayToExclusive, + int needCapPerRoom, List scopedHotelIds) { LocalDate from = stayFrom != null ? stayFrom : LocalDate.now(); LocalDate toEx = stayToExclusive; if (toEx == null || !toEx.isAfter(from)) { - // 未传离店:只扫未来 30 天有日历的酒店,避免无上界扫全表 - toEx = from.plusDays(30); + // 未传离店:只扫入住日起 2 天(列表展示够用),避免默认扫 30 天全库 + toEx = from.plusDays(2); } - String cacheKey = CANDIDATE_CACHE_PREFIX + from + ":" + toEx + ":" + needCapPerRoom; + // 全国无缩小范围:不要 SELECT DISTINCT 全库日历(百万级会拖死),交给 EXISTS + if (scopedHotelIds == null) { + return null; + } + if (scopedHotelIds.isEmpty()) { + return List.of(); + } + String scopeDigest = Integer.toHexString(scopedHotelIds.hashCode()); + String cacheKey = CANDIDATE_CACHE_PREFIX + from + ":" + toEx + ":" + needCapPerRoom + ":" + scopeDigest + ":" + + scopedHotelIds.size(); try { String cached = stringRedisTemplate.opsForValue().get(cacheKey); if (CANDIDATE_CACHE_EMPTY.equals(cached)) { @@ -661,7 +744,7 @@ public class SmHotelAppController { catch (Exception e) { log.warn("|酒店App|候选缓存读取失败|{}", e.getMessage()); } - List bookable = dailyMapper.selectDistinctHotelIdsByStayRange(from, toEx); + List bookable = dailyMapper.selectDistinctHotelIdsByStayRangeInHotels(from, toEx, scopedHotelIds); if (bookable == null || bookable.isEmpty()) { cacheCandidateIds(cacheKey, CANDIDATE_CACHE_EMPTY); return List.of(); @@ -697,18 +780,87 @@ public class SmHotelAppController { } } - /** 可售:IN(日历 DISTINCT 子查询),替代相关 EXISTS */ + private String buildPageCacheKey(long current, long size, String keyword, String city, String district, + Long districtAdcode, String bizArea, String starLevels, BigDecimal minScore, BigDecimal minPrice, + BigDecimal maxPrice, String facilityTags, Integer rooms, Integer adults, Integer children, + Integer guests, String checkIn, String checkOut) { + return PAGE_CACHE_PREFIX + current + ":" + size + ":" + StrUtil.nullToEmpty(keyword) + ":" + + StrUtil.nullToEmpty(city) + ":" + StrUtil.nullToEmpty(district) + ":" + + (districtAdcode == null ? "" : districtAdcode) + ":" + StrUtil.nullToEmpty(bizArea) + ":" + + StrUtil.nullToEmpty(starLevels) + ":" + (minScore == null ? "" : minScore) + ":" + + (minPrice == null ? "" : minPrice) + ":" + (maxPrice == null ? "" : maxPrice) + ":" + + StrUtil.nullToEmpty(facilityTags) + ":" + rooms + ":" + adults + ":" + children + ":" + guests + + ":" + StrUtil.nullToEmpty(checkIn) + ":" + StrUtil.nullToEmpty(checkOut); + } + + @SuppressWarnings("unchecked") + private Page> readPageCache(String key) { + try { + String cached = stringRedisTemplate.opsForValue().get(key); + if (StrUtil.isBlank(cached)) { + return null; + } + JSONObject obj = JSONUtil.parseObj(cached); + Page> p = new Page<>(obj.getLong("current", 1L), obj.getLong("size", 10L), + obj.getLong("total", 0L)); + Object records = obj.get("records"); + if (records instanceof List list) { + List> rows = new ArrayList<>(list.size()); + for (Object o : list) { + if (o instanceof Map m) { + rows.add(new LinkedHashMap<>((Map) m)); + } + else if (o != null) { + rows.add(JSONUtil.parseObj(JSONUtil.toJsonStr(o))); + } + } + p.setRecords(rows); + } + else { + p.setRecords(List.of()); + } + return p; + } + catch (Exception e) { + log.warn("|酒店App|page缓存读取失败|{}", e.getMessage()); + return null; + } + } + + private void writePageCache(String key, Page> page) { + if (page == null || StrUtil.isBlank(key)) { + return; + } + try { + Map payload = new LinkedHashMap<>(4); + payload.put("current", page.getCurrent()); + payload.put("size", page.getSize()); + payload.put("total", page.getTotal()); + payload.put("records", page.getRecords()); + stringRedisTemplate.opsForValue() + .set(key, JSONUtil.toJsonStr(payload), PAGE_CACHE_SECONDS, TimeUnit.SECONDS); + } + catch (Exception e) { + log.warn("|酒店App|page缓存写入失败|{}", e.getMessage()); + } + } + + /** + * 可售:EXISTS 按 hotel_id 点查日历(配 idx_daily_hotel_stay)。 + * 全国列表走此路径,避免先 DISTINCT 全库再超长 IN。 + */ private void applyBookableHotelFilter(LambdaQueryWrapper wrapper, LocalDate stayFrom, LocalDate stayToExclusive) { LocalDate from = stayFrom != null ? stayFrom : LocalDate.now(); LocalDate toEx = stayToExclusive; if (toEx == null || !toEx.isAfter(from)) { - toEx = from.plusDays(30); + toEx = from.plusDays(2); } wrapper.apply(""" - id IN ( - SELECT DISTINCT d.hotel_id FROM canteen_hotel_daily d - WHERE d.del_flag = '0' + EXISTS ( + SELECT 1 FROM canteen_hotel_daily d + WHERE d.hotel_id = id + AND d.del_flag = '0' AND d.stay_date >= {0} AND d.stay_date < {1} ) diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java index 33d39a2e8..ee3e0aac8 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOpenApiController.java @@ -63,6 +63,22 @@ public class SmHotelOpenApiController { (app, body) -> openApiService.hotelDaily(app, body)); } + @Inner(value = false) + @Operation(summary = "筛选项(授权范围内区域/商圈/星级)") + @PostMapping("/hotel/filter-meta") + public R> hotelFilterMeta(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_FILTER_META, + (app, body) -> openApiService.hotelFilterMeta(app, body)); + } + + @Inner(value = false) + @Operation(summary = "增量同步拉取(下游落库)") + @PostMapping("/hotel/sync") + public R> hotelSync(HttpServletRequest request, @RequestBody String rawBody) { + return guarded(request, rawBody, SmHotelOpenApi.API_HOTEL_SYNC, + (app, body) -> openApiService.hotelSync(app, body)); + } + @Inner(value = false) @Operation(summary = "下单前可订校验") @PostMapping("/order/check") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java index 10272b091..e70781a8d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouHotelClient.java @@ -679,16 +679,18 @@ public class ShangyouHotelClient { /** * 下单/按单号查询专用:不因业务码非 200 抛错(3028 等需补偿);HTTP 5xx/超时标 uncertain。 + * 业务码 500「系统异常/请稍后重试」按瞬时繁忙退避重试,耗尽后转 uncertain 走补偿。 */ private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body) { - return postOrderBiz(path, body, false, null); - } - - private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried) { - return postOrderBiz(path, body, retried, null); + return postOrderBiz(path, body, false, null, 0); } private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried, String invalidToken) { + return postOrderBiz(path, body, retried, invalidToken, 0); + } + + private ShangyouOrderCallResult postOrderBiz(String path, JSONObject body, boolean retried, String invalidToken, + int busyRound) { ensureEnabled(); String token; try { @@ -698,7 +700,8 @@ public class ShangyouHotelClient { return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null); } String url = props().resolveBizBaseUrl() + path; - log.info("shangyou order request path={} retried={} bodyKeys={}", path, retried, body.keySet()); + log.info("shangyou order request path={} retried={} busyRound={} bodyKeys={}", path, retried, busyRound, + body.keySet()); try (HttpResponse response = HttpRequest.post(url) .header("Authorization", "Bearer " + token) .header("Accept", "*/*") @@ -711,12 +714,18 @@ public class ShangyouHotelClient { int status = response.getStatus(); if (status == 401 || isTokenExpiredBiz(respBody)) { if (!retried) { - return postOrderBiz(path, body, true, token); + return postOrderBiz(path, body, true, token, busyRound); } return ShangyouOrderCallResult.uncertain("Token 失效且重试仍失败: " + StrUtil.sub(respBody, 0, 300), status); } if (status >= 500 || status == 408 || status == 429) { + if (busyRound < 4) { + sleepQuiet(busyBackoffMs(busyRound)); + log.warn("shangyou order HTTP busy, retry path={} nextBusy={} status={}", path, busyRound + 1, + status); + return postOrderBiz(path, body, false, null, busyRound + 1); + } return ShangyouOrderCallResult.uncertain("HTTP=" + status + " body=" + StrUtil.sub(respBody, 0, 300), status); } @@ -739,10 +748,22 @@ public class ShangyouHotelClient { JSONObject resp = JSON.parseObject(respBody); if (isTokenExpiredBiz(resp)) { if (!retried) { - return postOrderBiz(path, body, true, token); + return postOrderBiz(path, body, true, token, busyRound); } return ShangyouOrderCallResult.uncertain("Token 业务失效且重试仍失败", status); } + // 业务码 500「系统异常:请稍后重试」等:与查价一致做退避重试 + if (isUpstreamBusy(resp) && busyRound < 4) { + sleepQuiet(busyBackoffMs(busyRound)); + log.warn("shangyou order busy, retry path={} nextBusy={} msg={}", path, busyRound + 1, + resp.getString("message")); + return postOrderBiz(path, body, false, null, busyRound + 1); + } + if (isUpstreamBusy(resp)) { + log.warn("shangyou order busy exhausted path={} msg={}", path, resp.getString("message")); + return ShangyouOrderCallResult.uncertain( + "上游繁忙/系统异常: " + StrUtil.blankToDefault(resp.getString("message"), "请稍后重试"), status); + } log.info("|上游订单响应|path={}|code={}|message={}|snippet={}|", path, resp.getString("code"), resp.getString("message"), StrUtil.sub(respBody, 0, 500)); return ShangyouOrderCallResult.ofResp(resp, status); @@ -753,6 +774,15 @@ public class ShangyouHotelClient { } } + private static long busyBackoffMs(int busyRound) { + return switch (busyRound) { + case 0 -> 800L; + case 1 -> 1500L; + case 2 -> 3000L; + default -> 5000L; + }; + } + public String resolveCallbackSecret() { return StrUtil.blankToDefault(props().getCallbackSecret(), props().getCardPassword()); } @@ -897,17 +927,23 @@ public class ShangyouHotelClient { || StrUtil.containsIgnoreCase(msg, "失效") || StrUtil.containsIgnoreCase(msg, "为空")); } - /** 上游瞬时繁忙(滚价高峰常见) */ + /** 上游瞬时繁忙(滚价高峰 / 系统异常:请稍后重试 常见) */ private static boolean isUpstreamBusy(JSONObject resp) { if (resp == null) { return false; } String code = firstNonBlank(resp.getString("code"), resp.getString("Code")); String msg = firstNonBlank(resp.getString("message"), resp.getString("msg")); - if ("500".equals(code) && StrUtil.containsIgnoreCase(msg, "繁忙")) { + if (StrUtil.isBlank(msg)) { + return false; + } + // 文档示例「繁忙」;实际常见「系统异常:请稍后重试」(稍后 ≠ 稍后再) + if ("500".equals(code) && (StrUtil.containsIgnoreCase(msg, "繁忙") || StrUtil.contains(msg, "系统异常") + || StrUtil.contains(msg, "请稍后") || StrUtil.contains(msg, "请稍候"))) { return true; } - return StrUtil.containsIgnoreCase(msg, "请稍后再试") || StrUtil.containsIgnoreCase(msg, "稍候再试"); + return StrUtil.containsIgnoreCase(msg, "请稍后再试") || StrUtil.containsIgnoreCase(msg, "稍候再试") + || StrUtil.contains(msg, "请稍后重试"); } private static void sleepQuiet(long ms) { 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 e7e6b16dc..8bf12f55f 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 @@ -42,6 +42,22 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper selectDistinctHotelIdsByStayRange(@Param("fromDate") LocalDate fromDate, @Param("toExclusive") LocalDate toExclusive); + /** + * 在给定酒店集合内查「入住区间有日历」的酒店(城市/关键字先缩小范围后再查,避免全库 DISTINCT)。 + */ + @Select(""" + + """) + List selectDistinctHotelIdsByStayRangeInHotels(@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/SmHotelOpenApiService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java index d00c9da01..064fdbe91 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenApiService.java @@ -17,6 +17,12 @@ public interface SmHotelOpenApiService { R> hotelDaily(SmHotelDownstreamAppEntity app, Map body); + /** 授权范围内区域/商圈/星级筛选项 */ + R> hotelFilterMeta(SmHotelDownstreamAppEntity app, Map body); + + /** 下游落库增量同步 */ + R> hotelSync(SmHotelDownstreamAppEntity app, Map body); + R> orderCheck(SmHotelDownstreamAppEntity app, Map body); R orderCreate(SmHotelDownstreamAppEntity app, Map body); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenResourceNotifyService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenResourceNotifyService.java new file mode 100644 index 000000000..f085633f5 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOpenResourceNotifyService.java @@ -0,0 +1,21 @@ +package com.bsd.pjcloud.supermarket.service; + +/** + * 开放应用:酒店资源变更回调(按渠道授权范围扇出)。 + */ +public interface SmHotelOpenResourceNotifyService { + + String CHANGE_UPSERT = "HOTEL_UPSERT"; + + String CHANGE_OFFLINE = "HOTEL_OFFLINE"; + + String CHANGE_DELETE = "HOTEL_DELETE"; + + String CHANGE_DETAIL = "HOTEL_DETAIL"; + + /** 按酒店主键通知(自动解析 hotelCode) */ + void notifyByHotelId(Long hotelId, String changeType); + + /** 按酒店编码通知(删除前可先记下 code) */ + void notifyByHotelCode(String hotelCode, Long hotelId, String changeType); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index 27ef129bf..20983daee 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService; import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService; import com.bsd.pjcloud.supermarket.service.SmHotelMidService; +import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO; import com.bsd.pjcloud.common.core.constant.CommonConstants; @@ -88,6 +89,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { private final SmHotelDownstreamAutoPushService downstreamAutoPushService; + private final SmHotelOpenResourceNotifyService openResourceNotifyService; + private final SmHotelMapper hotelMapper; private final SmHotelRoomMapper roomMapper; @@ -102,7 +105,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties, SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService, SmHotelDownstreamPushLogService downstreamPushLogService, - @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService, SmHotelMapper hotelMapper, + @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService, + @Lazy SmHotelOpenResourceNotifyService openResourceNotifyService, SmHotelMapper hotelMapper, SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper, StringRedisTemplate stringRedisTemplate) { this.upstreamClient = upstreamClient; @@ -114,6 +118,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { this.channelDeptService = channelDeptService; this.downstreamPushLogService = downstreamPushLogService; this.downstreamAutoPushService = downstreamAutoPushService; + this.openResourceNotifyService = openResourceNotifyService; this.hotelMapper = hotelMapper; this.roomMapper = roomMapper; this.productMapper = productMapper; @@ -1490,6 +1495,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { roomMapper.updateById(room); } downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL); return R.ok(roomMapper.selectById(room.getId())); } @@ -1510,6 +1516,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { boolean ok = roomMapper.deleteById(roomId) > 0; if (ok) { downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL); } return R.ok(ok); } @@ -1574,6 +1581,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { productMapper.updateById(product); } downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL); return R.ok(productMapper.selectById(product.getId())); } @@ -1592,6 +1600,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { boolean ok = productMapper.deleteById(productId) > 0; if (ok) { downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + openResourceNotifyService.notifyByHotelId(hotelId, SmHotelOpenResourceNotifyService.CHANGE_DETAIL); } return R.ok(ok); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java index a9078c5df..0e670d22c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenApiServiceImpl.java @@ -1,10 +1,13 @@ package com.bsd.pjcloud.supermarket.service.impl; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.config.SmHotelOpenProperties; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; @@ -14,24 +17,47 @@ 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.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelOpenApiService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; +import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; 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; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +/** + * 下游统一开放 API:资源与下单均受「渠道部门映射」授权约束(按部门 / 按指定酒店)。 + * 伙伴编码优先 {@code partnerCode},空则回落 {@code appKey},须与映射表 channel 一致。 + * 图片字段对相对路径按 {@code sm-hotel.open.public-file-base-url} 拼成可外网访问的绝对地址。 + */ @Service @RequiredArgsConstructor public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final Map STAR_LABEL = Map.of(1, "民宿", 2, "经济", 3, "舒适", 4, "高档", 5, "五星"); + private final SmHotelMapper hotelMapper; private final SmHotelRoomMapper roomMapper; @@ -42,25 +68,45 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { private final SmHotelOrderService hotelOrderService; + private final SmHotelChannelDeptService channelDeptService; + + private final SmHotelOpenProperties openProperties; + @Override public R> hotelList(SmHotelDownstreamAppEntity app, Map body) { + R> scopeR = requireAllowedHotelIds(app); + if (!scopeR.isOk()) { + return R.failed(scopeR.getMsg()); + } + List allowedIds = scopeR.getData(); long current = toLong(body.get("current"), 1L); long size = Math.min(100L, toLong(body.get("size"), 20L)); String keyword = StrUtil.trim(str(body.get("keyword"))); String district = StrUtil.trim(str(body.get("district"))); - Page page = hotelMapper.selectPage(new Page<>(current, size), - Wrappers.lambdaQuery() - .eq(SmHotelEntity::getUpstreamStatus, "ACTIVE") - .and(StrUtil.isNotBlank(keyword), w -> w.like(SmHotelEntity::getHotelName, keyword) - .or() - .like(SmHotelEntity::getHotelCode, keyword) - .or() - .like(SmHotelEntity::getAddress, keyword)) - .like(StrUtil.isNotBlank(district), SmHotelEntity::getDistrict, district) - .orderByDesc(SmHotelEntity::getUpdateTime)); + Long districtAdcode = toLongObj(body.get("districtAdcode")); + String bizArea = StrUtil.trim(str(body.get("bizArea"))); + LambdaQueryWrapper qw = Wrappers.lambdaQuery() + .in(SmHotelEntity::getId, allowedIds) + .eq(SmHotelEntity::getUpstreamStatus, "ACTIVE") + .and(StrUtil.isNotBlank(keyword), w -> w.like(SmHotelEntity::getHotelName, keyword) + .or() + .like(SmHotelEntity::getHotelCode, keyword) + .or() + .like(SmHotelEntity::getAddress, keyword)) + .like(StrUtil.isNotBlank(bizArea), SmHotelEntity::getBizArea, bizArea) + .orderByDesc(SmHotelEntity::getUpdateTime); + HotelAreaAdcodeUtil.applyHotelListFilter(qw, districtAdcode, district); + Page page = hotelMapper.selectPage(new Page<>(current, size), qw); + List hotels = page.getRecords(); + List pageIds = hotels.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).toList(); + Map minPriceMap = loadMinSellPrice(pageIds); List> records = new ArrayList<>(); - for (SmHotelEntity h : page.getRecords()) { - records.add(toHotelBrief(h)); + for (SmHotelEntity h : hotels) { + Map row = toHotelBrief(h); + if (h.getId() != null) { + row.put("minSellPrice", minPriceMap.get(h.getId())); + } + records.add(row); } Map data = new LinkedHashMap<>(8); data.put("records", records); @@ -70,6 +116,107 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { return R.ok(data); } + @Override + public R> hotelFilterMeta(SmHotelDownstreamAppEntity app, Map body) { + R> scopeR = requireAllowedHotelIds(app); + if (!scopeR.isOk()) { + return R.failed(scopeR.getMsg()); + } + List allowedIds = scopeR.getData(); + // 授权范围内轻量扫:仅取区域相关列 + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, SmHotelEntity::getBizArea) + .in(SmHotelEntity::getId, allowedIds) + .eq(SmHotelEntity::getUpstreamStatus, "ACTIVE")); + Map areaByAdcode = new LinkedHashMap<>(); + Set districtNames = new LinkedHashSet<>(); + Set bizAreas = new LinkedHashSet<>(); + for (SmHotelEntity h : rows) { + if (h == null) { + continue; + } + String dName = cleanTextField(h.getDistrict()); + if (StrUtil.isNotBlank(dName)) { + districtNames.add(dName); + } + if (h.getDistrictAdcode() != null) { + areaByAdcode.putIfAbsent(h.getDistrictAdcode(), StrUtil.blankToDefault(dName, String.valueOf(h.getDistrictAdcode()))); + } + String ba = cleanTextField(h.getBizArea()); + if (StrUtil.isNotBlank(ba)) { + bizAreas.add(ba); + } + } + List> areas = areaByAdcode.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> { + Map one = new LinkedHashMap<>(4); + one.put("districtAdcode", e.getKey()); + one.put("district", e.getValue()); + return one; + }) + .collect(Collectors.toList()); + List> starLevels = STAR_LABEL.entrySet().stream() + .sorted(Comparator.comparingInt(Map.Entry::getKey).reversed()) + .map(e -> { + Map one = new LinkedHashMap<>(2); + one.put("value", e.getKey()); + one.put("label", e.getValue()); + return one; + }) + .collect(Collectors.toList()); + Map data = new LinkedHashMap<>(8); + data.put("areas", areas); + data.put("districts", new ArrayList<>(districtNames)); + data.put("bizAreas", new ArrayList<>(bizAreas)); + data.put("starLevels", starLevels); + return R.ok(data); + } + + @Override + public R> hotelSync(SmHotelDownstreamAppEntity app, Map body) { + R> scopeR = requireAllowedHotelIds(app); + if (!scopeR.isOk()) { + return R.failed(scopeR.getMsg()); + } + List allowedIds = scopeR.getData(); + long current = toLong(body.get("current"), 1L); + long size = Math.min(100L, toLong(body.get("size"), 50L)); + LocalDateTime updatedSince = parseDateTime(str(body.get("updatedSince"))); + boolean includeOffline = Boolean.TRUE.equals(body.get("includeOffline")) + || "1".equals(str(body.get("includeOffline"))) + || "true".equalsIgnoreCase(str(body.get("includeOffline"))); + LambdaQueryWrapper qw = Wrappers.lambdaQuery() + .in(SmHotelEntity::getId, allowedIds) + .ge(updatedSince != null, SmHotelEntity::getUpdateTime, updatedSince) + .orderByAsc(SmHotelEntity::getUpdateTime) + .orderByAsc(SmHotelEntity::getId); + if (!includeOffline) { + qw.eq(SmHotelEntity::getUpstreamStatus, "ACTIVE"); + } + Page page = hotelMapper.selectPage(new Page<>(current, size), qw); + List> records = new ArrayList<>(); + for (SmHotelEntity h : page.getRecords()) { + Map row = toHotelBrief(h); + // 同步包:附带简介摘要标记,详情仍走 hotel/detail + row.put("hasIntro", StrUtil.isNotBlank(h.getIntro())); + records.add(row); + } + LocalDateTime maxUt = page.getRecords().stream() + .map(SmHotelEntity::getUpdateTime) + .filter(Objects::nonNull) + .max(LocalDateTime::compareTo) + .orElse(updatedSince); + Map data = new LinkedHashMap<>(8); + data.put("records", records); + data.put("total", page.getTotal()); + data.put("current", page.getCurrent()); + data.put("size", page.getSize()); + data.put("nextUpdatedSince", maxUt == null ? null : maxUt.format(DT)); + data.put("hasMore", page.getCurrent() * page.getSize() < page.getTotal()); + return R.ok(data); + } + @Override public R> hotelDetail(SmHotelDownstreamAppEntity app, Map body) { String hotelCode = StrUtil.trim(str(body.get("hotelCode"))); @@ -82,11 +229,16 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { if (hotel == null) { return R.failed("酒店不存在"); } + R auth = assertHotelInScope(app, hotel.getId()); + if (!auth.isOk()) { + return R.failed(auth.getMsg()); + } Map data = toHotelBrief(hotel); - data.put("coverImage", hotel.getCoverImage()); - data.put("carouselImages", hotel.getCarouselImages()); - data.put("intro", hotel.getIntro()); + // coverImage 已在 brief 中绝对化;轮播与简介内图片一并绝对化 + data.put("carouselImages", toPublicUrlCsv(hotel.getCarouselImages())); + data.put("intro", toPublicHtml(hotel.getIntro())); data.put("starLevel", hotel.getStarLevel()); + data.put("facilityTags", hotel.getFacilityTags()); List rooms = roomMapper.selectList(Wrappers.lambdaQuery() .eq(SmHotelRoomEntity::getHotelId, hotel.getId()) .orderByAsc(SmHotelRoomEntity::getRoomTypeCode)); @@ -137,6 +289,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { if (hotel == null) { return R.failed("酒店不存在"); } + R auth = assertHotelInScope(app, hotel.getId()); + if (!auth.isOk()) { + return R.failed(auth.getMsg()); + } List dailies = dailyMapper.selectList(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotel.getId()) .eq(StrUtil.isNotBlank(productCode), SmHotelDailyEntity::getProductCode, productCode) @@ -165,6 +321,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { @Override public R> orderCheck(SmHotelDownstreamAppEntity app, Map body) { + R auth = assertHotelCodeInScope(app, str(body.get("hotelCode"))); + if (!auth.isOk()) { + return R.failed(auth.getMsg()); + } Map checkBody = new LinkedHashMap<>(); checkBody.put("extHotelId", body.get("hotelCode")); checkBody.put("extProductId", body.get("productCode")); @@ -173,20 +333,33 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { checkBody.put("checkOut", body.get("checkOutDate")); checkBody.put("roomCnt", body.get("count") != null ? body.get("count") : body.get("quantity")); checkBody.put("price", body.get("totalAmount") != null ? body.get("totalAmount") : body.get("totalPrice")); - return hotelOrderService.checkBookableForDownstream(checkBody); + R> r = hotelOrderService.checkBookableForDownstream(checkBody); + if (r == null || !r.isOk()) { + return r == null ? R.failed("可订校验失败") : r; + } + Map data = r.getData(); + if (data != null && Boolean.FALSE.equals(data.get("bookable"))) { + Object reasons = data.get("reasons"); + String msg = reasons instanceof java.util.Collection && !((java.util.Collection) reasons).isEmpty() + ? StrUtil.join(";", (java.util.Collection) reasons) + : "当前不可订"; + return R.failed(msg); + } + return r; } @Override public R orderCreate(SmHotelDownstreamAppEntity app, Map body) { - // 保证订单来源写入伙伴编码 - R r = hotelOrderService.createFromDownstream(app.getAppKey(), body); - return r; + R auth = assertHotelCodeInScope(app, str(body.get("hotelCode"))); + if (!auth.isOk()) { + return R.failed(auth.getMsg()); + } + return hotelOrderService.createFromDownstream(app.getAppKey(), body); } @Override public R orderCancel(SmHotelDownstreamAppEntity app, Map body) { - R r = hotelOrderService.cancelFromDownstream(app.getAppKey(), body); - return r; + return hotelOrderService.cancelFromDownstream(app.getAppKey(), body); } @Override @@ -194,21 +367,176 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { return hotelOrderService.queryFromDownstream(app.getAppKey(), body); } + /** 开放应用 → 渠道映射用的伙伴编码 */ + static String resolvePartnerCode(SmHotelDownstreamAppEntity app) { + if (app == null) { + return ""; + } + return StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey())); + } + + /** + * 解析授权酒店 ID;未配置任何启用映射时拒绝(避免开放应用默认可看全库)。 + */ + private R> requireAllowedHotelIds(SmHotelDownstreamAppEntity app) { + String partner = resolvePartnerCode(app); + if (StrUtil.isBlank(partner)) { + return R.failed("开放应用未配置伙伴编码"); + } + List ids = channelDeptService.listHotelIdsForDownstreamPartner(partner); + if (CollUtil.isEmpty(ids)) { + return R.failed("未配置授权酒店范围,请先在「渠道部门映射」为伙伴「" + partner + + "」配置下游授权(按部门或指定酒店)并启用"); + } + return R.ok(ids); + } + + private R assertHotelInScope(SmHotelDownstreamAppEntity app, Long hotelId) { + if (hotelId == null) { + return R.failed("酒店不存在"); + } + R> scopeR = requireAllowedHotelIds(app); + if (!scopeR.isOk()) { + return R.failed(scopeR.getMsg()); + } + Set allowed = new HashSet<>(scopeR.getData()); + if (!allowed.contains(hotelId)) { + return R.failed("该酒店不在下游授权范围内"); + } + return R.ok(); + } + + private R assertHotelCodeInScope(SmHotelDownstreamAppEntity app, String hotelCode) { + if (StrUtil.isBlank(hotelCode)) { + return R.failed("hotelCode 不能为空"); + } + SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .select(SmHotelEntity::getId) + .eq(SmHotelEntity::getHotelCode, StrUtil.trim(hotelCode)) + .last("limit 1")); + if (hotel == null || hotel.getId() == null) { + return R.failed("酒店不存在"); + } + return assertHotelInScope(app, hotel.getId()); + } + private Map toHotelBrief(SmHotelEntity h) { - Map row = new LinkedHashMap<>(12); + Map row = new LinkedHashMap<>(18); row.put("hotelCode", h.getHotelCode()); row.put("hotelName", h.getHotelName()); row.put("groupCode", h.getGroupCode()); - row.put("address", h.getAddress()); - row.put("district", h.getDistrict()); - row.put("bizArea", h.getBizArea()); + row.put("coverImage", toPublicUrl(h.getCoverImage())); + row.put("address", cleanTextField(h.getAddress())); + row.put("district", cleanTextField(h.getDistrict())); + row.put("districtAdcode", h.getDistrictAdcode()); + row.put("bizArea", cleanTextField(h.getBizArea())); + row.put("starLevel", h.getStarLevel()); + row.put("score", h.getScore()); + row.put("facilityTags", cleanTextField(h.getFacilityTags())); row.put("longitude", h.getLongitude()); row.put("latitude", h.getLatitude()); + row.put("phone", cleanTextField(h.getPhone())); row.put("dataSource", h.getSheetName()); row.put("status", h.getUpstreamStatus()); + row.put("updateTime", h.getUpdateTime() == null ? null : h.getUpdateTime().format(DT)); return row; } + private Map loadMinSellPrice(List hotelIds) { + Map map = new HashMap<>(); + if (CollUtil.isEmpty(hotelIds)) { + return map; + } + List> rows = productMapper.selectMinSellPriceByHotelIds(hotelIds); + if (rows == null) { + return map; + } + for (Map row : rows) { + if (row == null || row.get("hotelId") == null || row.get("minSell") == null) { + continue; + } + try { + map.put(Long.valueOf(String.valueOf(row.get("hotelId"))), + new BigDecimal(String.valueOf(row.get("minSell")))); + } + catch (Exception ignored) { + // skip bad row + } + } + return map; + } + + /** 过滤误存的 [] / {} / () 等占位串,避免下游直接展示 */ + private static String cleanTextField(String v) { + if (StrUtil.isBlank(v)) { + return null; + } + String s = v.trim(); + if ("[]".equals(s) || "{}".equals(s) || "()".equals(s) || "()".equals(s) + || "null".equalsIgnoreCase(s)) { + return null; + } + return s; + } + + /** + * 相对路径拼公网根地址;已是 http(s) 则原样返回。 + */ + private String toPublicUrl(String path) { + if (StrUtil.isBlank(path)) { + return path; + } + String p = path.trim(); + if (StrUtil.startWithIgnoreCase(p, "http://") || StrUtil.startWithIgnoreCase(p, "https://") + || StrUtil.startWithIgnoreCase(p, "data:")) { + return p; + } + String base = StrUtil.trim(openProperties.getPublicFileBaseUrl()); + if (StrUtil.isBlank(base)) { + return p; + } + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + // 库内偶发带 /api 前缀时,与 base 末尾 /api 去重 + if (base.endsWith("/api") && (p.startsWith("/api/") || "/api".equals(p))) { + p = p.substring("/api".length()); + } + if (!p.startsWith("/")) { + p = "/" + p; + } + return base + p; + } + + /** 逗号分隔多图,逐个绝对化 */ + private String toPublicUrlCsv(String csv) { + if (StrUtil.isBlank(csv)) { + return csv; + } + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(StrUtil::isNotBlank) + .map(this::toPublicUrl) + .collect(Collectors.joining(",")); + } + + private static final Pattern HTML_URL_ATTR = Pattern.compile("(?i)(\\b(?:src|href)=[\"'])([^\"']+)([\"'])"); + + /** 富文本内 img/src、href 相对路径绝对化,便于下游小程序直接渲染 */ + private String toPublicHtml(String html) { + if (StrUtil.isBlank(html)) { + return html; + } + Matcher m = HTML_URL_ATTR.matcher(html); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String abs = toPublicUrl(m.group(2)); + m.appendReplacement(sb, Matcher.quoteReplacement(m.group(1) + abs + m.group(3))); + } + m.appendTail(sb); + return sb.toString(); + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } @@ -225,6 +553,39 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService { } } + private static Long toLongObj(Object o) { + if (o == null || StrUtil.isBlank(String.valueOf(o))) { + return null; + } + try { + return Long.parseLong(String.valueOf(o).trim()); + } + catch (Exception e) { + return null; + } + } + + private static LocalDateTime parseDateTime(String s) { + if (StrUtil.isBlank(s)) { + return null; + } + String t = s.trim(); + try { + if (t.length() == 10) { + return LocalDate.parse(t, DAY).atStartOfDay(); + } + return LocalDateTime.parse(t, DT); + } + catch (Exception e) { + try { + return LocalDateTime.parse(t.replace('T', ' ').substring(0, Math.min(19, t.length())), DT); + } + catch (Exception ignored) { + return null; + } + } + } + private static LocalDate parseDay(String s) { if (StrUtil.isBlank(s)) { return null; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenResourceNotifyServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenResourceNotifyServiceImpl.java new file mode 100644 index 000000000..101b8645d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOpenResourceNotifyServiceImpl.java @@ -0,0 +1,112 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenCallbackClient; +import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; +import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 启用回调的开放应用 ∩ 渠道部门映射授权 → 异步推送资源变更。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelOpenResourceNotifyServiceImpl implements SmHotelOpenResourceNotifyService { + + public static final String CHANGE_UPSERT = "HOTEL_UPSERT"; + + public static final String CHANGE_OFFLINE = "HOTEL_OFFLINE"; + + public static final String CHANGE_DELETE = "HOTEL_DELETE"; + + public static final String CHANGE_DETAIL = "HOTEL_DETAIL"; + + private final SmHotelMapper hotelMapper; + + private final SmHotelDownstreamAppMapper appMapper; + + private final SmHotelChannelDeptService channelDeptService; + + private final DownstreamOpenCallbackClient callbackClient; + + @Override + public void notifyByHotelId(Long hotelId, String changeType) { + if (hotelId == null) { + return; + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null || StrUtil.isBlank(hotel.getHotelCode())) { + return; + } + String type = StrUtil.blankToDefault(changeType, CHANGE_UPSERT); + if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus()) && CHANGE_UPSERT.equals(type)) { + type = CHANGE_OFFLINE; + } + notifyByHotelCode(hotel.getHotelCode(), hotelId, type); + } + + @Override + public void notifyByHotelCode(String hotelCode, Long hotelId, String changeType) { + if (StrUtil.isBlank(hotelCode)) { + return; + } + List apps = appMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDownstreamAppEntity::getStatus, "1") + .eq(SmHotelDownstreamAppEntity::getCallbackEnabled, "1")); + if (CollUtil.isEmpty(apps)) { + return; + } + String type = StrUtil.blankToDefault(changeType, CHANGE_UPSERT); + Map payload = new LinkedHashMap<>(4); + payload.put("hotelCode", hotelCode.trim()); + payload.put("changeType", type); + int hit = 0; + for (SmHotelDownstreamAppEntity app : apps) { + if (app == null || StrUtil.hasBlank(app.getCallbackBaseUrl(), app.getCallbackResourcePath())) { + continue; + } + String partner = StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey())); + if (StrUtil.isBlank(partner)) { + continue; + } + if (hotelId != null && !inScope(partner, hotelId)) { + continue; + } + callbackClient.notifyResourceChangeAsync(app.getId(), hotelCode.trim(), type, payload); + hit++; + } + if (hit > 0) { + log.info("|开放资源回调|hotelCode={}|changeType={}|apps={}|", hotelCode, type, hit); + } + } + + private boolean inScope(String partner, Long hotelId) { + try { + List ids = channelDeptService.listHotelIdsForDownstreamPartner(partner); + if (CollUtil.isEmpty(ids)) { + return false; + } + Set set = new HashSet<>(ids); + return set.contains(hotelId); + } + catch (Exception e) { + log.warn("|开放资源回调|范围解析失败|partner={}|{}", partner, e.getMessage()); + return false; + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index 2c6084f81..66df0d2f1 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -1,5 +1,6 @@ package com.bsd.pjcloud.supermarket.service.impl; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; @@ -19,6 +20,7 @@ import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrder import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus; import com.bsd.pjcloud.supermarket.mapper.*; import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; +import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; @@ -98,6 +100,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl allowed = channelDeptService.listHotelIdsForDownstreamPartner(partnerForScope); + if (CollUtil.isEmpty(allowed) || !allowed.contains(hotel.getId())) { + return R.failed("该酒店不在下游授权范围内,请先在「渠道部门映射」配置"); + } + } SmHotelProductEntity product = null; if (StrUtil.isNotBlank(productCode)) { product = productMapper.selectOne(Wrappers.lambdaQuery() @@ -2526,8 +2539,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl *

  • 中文:首字为姓,其余为名(复姓未特殊处理;单字名时名为 "/")
  • - *
  • 英文空格分隔:按 First Last 或 Last First 取两段
  • - *
  • 其它:整段进 LastName,FirstName="/"
  • + *
  • 英文空格分隔:取末段为姓、首段为名
  • + *
  • 英文/拼音单段:姓、名都用整段(勿填 "/",商游 order/add 易直接 500)
  • * */ private static NameParts splitPersonName(String raw) { @@ -2548,7 +2561,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl= 2) { return new NameParts(parts[parts.length - 1], parts[0]); } - return new NameParts(name, "/"); + // 拼音/英文单段:避免 firstName="/" 被上游拒 + return new NameParts(name, name); } private record NameParts(String lastName, String firstName) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java index 1b9a935bf..5eb29d45d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java @@ -21,6 +21,7 @@ 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.service.SmHotelDownstreamAutoPushService; +import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService; import com.bsd.pjcloud.supermarket.service.SmHotelService; import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO; import com.bsd.pjcloud.common.core.constant.CommonConstants; @@ -65,12 +66,16 @@ public class SmHotelServiceImpl extends ServiceImpl idList = CollUtil.toList(ids); + List before = baseMapper.selectBatchIds(idList); + boolean ok = removeByIds(idList); + if (ok && CollUtil.isNotEmpty(before)) { + for (SmHotelEntity h : before) { + if (h != null && StrUtil.isNotBlank(h.getHotelCode())) { + openResourceNotifyService.notifyByHotelCode(h.getHotelCode(), h.getId(), + SmHotelOpenResourceNotifyService.CHANGE_DELETE); + } + } + } + return ok; } @Override diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml index 479cfe89c..f6432576a 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml @@ -110,6 +110,13 @@ spring: # # 同步后:已停用但上游又出现 → 自动恢复 ACTIVE # auto-reactivate-online-hotels: true # +## ========== 下游开放平台(/third/party/open)========== +## 图片公网根地址:相对路径(如 /admin/sys-file/...)会拼此前缀后下发给下游 +sm-hotel: + open: +# public-file-base-url: https://www.zianxin.com/api + public-file-base-url: http://127.0.0.1:9999 + ## ========== 下游销售方推送(环启等;优先走库表「渠道对接配置」direction=downstream)========== ## 本系统作为 A,主动 POST 到 B 的 /third/party/api/*;可配置多家;部门范围见「渠道部门映射」 ## 下列 YAML 仅作无库表时的单下游兼容回落(partner=default)