From eb242ef185411dd90a62813fcf9a2f355f610209 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Mon, 2 Nov 2026 19:45:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E5=A6=82=E9=85=92=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/enums/ErrorCodeConstants.java | 3 + .../app/hotel/AppHotelController.java | 108 +++++++++++++++++- .../dal/dataobject/hotel/HotelZaxDO.java | 8 +- .../framework/hotel/ZaxHotelOpenClient.java | 22 +++- .../hotel/HotelZaxAdminServiceImpl.java | 9 +- .../service/hotel/HotelZaxLocalService.java | 6 + .../hotel/HotelZaxLocalServiceImpl.java | 64 ++++++++++- .../service/hotel/HotelZaxSyncService.java | 7 ++ .../hotel/HotelZaxSyncServiceImpl.java | 90 +++++++++++++-- .../hotel/HotelZaxTradeServiceImpl.java | 43 ++++++- 10 files changed, 334 insertions(+), 26 deletions(-) diff --git a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java index 22dcd17..88b4ad3 100644 --- a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java @@ -82,5 +82,8 @@ public interface ErrorCodeConstants { ErrorCode HOTEL_ZAX_DAILY_NOT_EXISTS = new ErrorCode(1_008_020_003, "本地酒店日历不存在"); ErrorCode HOTEL_ZAX_ORDER_NOT_EXISTS = new ErrorCode(1_008_020_004, "本地酒店订单不存在"); ErrorCode HOTEL_ZAX_ORDER_CANCEL_FAIL = new ErrorCode(1_008_020_005, "酒店订单取消失败:{}"); + /** 供应方业务拒绝(可订校验/下单等),msg 透传给 C 端 */ + ErrorCode HOTEL_ZAX_SUPPLIER_BIZ_FAIL = new ErrorCode(1_008_020_006, "{}"); + ErrorCode HOTEL_ZAX_OFFLINE = new ErrorCode(1_008_020_007, "酒店已下架"); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java index a460b73..8b2582c 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONObject; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.product.enums.ErrorCodeConstants; import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenClient; import cn.iocoder.yudao.module.product.service.hotel.HotelZaxLocalService; import cn.iocoder.yudao.module.product.service.hotel.HotelZaxSyncService; @@ -73,6 +74,11 @@ public class AppHotelController { String hotelCode = body == null ? null : str(body.get("hotelCode")); Map local = hotelZaxLocalService.detail(hotelCode); if (local != null) { + String st = str(local.get("status")); + if (Boolean.TRUE.equals(local.get("offline")) + || (StrUtil.isNotBlank(st) && !"ACTIVE".equalsIgnoreCase(st))) { + return CommonResult.error(ErrorCodeConstants.HOTEL_ZAX_OFFLINE); + } return success(local); } return success(dataOf(zaxHotelOpenClient.post("hotel/detail", body))); @@ -80,13 +86,24 @@ public class AppHotelController { @PermitAll @PostMapping("/daily") - @Operation(summary = "价态日历(有本地日历用本地,否则供应方)") + @Operation(summary = "价态日历(供应方与本地合并;同产品同日期以供应方为准)") public CommonResult daily(@RequestBody Map body) { - Map local = hotelZaxLocalService.daily(body); - if (local != null) { - return success(local); + Object remote = null; + try { + remote = dataOf(zaxHotelOpenClient.post("hotel/daily", body)); } - return success(dataOf(zaxHotelOpenClient.post("hotel/daily", body))); + catch (Exception ignored) { + // 供应方超时/鉴权失败时仍可用本地镜像 + } + Map local = null; + try { + local = hotelZaxLocalService.daily(body); + } + catch (Exception ignored) { + // ignore + } + Object merged = mergeDaily(body == null ? null : str(body.get("hotelCode")), remote, local); + return success(merged); } @PostMapping("/order/check") @@ -186,6 +203,87 @@ public class AppHotelController { return r.get("data"); } + /** + * 合并供应方/本地日历。 + *

+ * 小如后台维护的本地日历优先:此前「供应方覆盖本地」会把本地已开房有库存冲成无库存,详情页全部售罄。 + * 供应方只补本地没有的产品/日期。 + */ + @SuppressWarnings("unchecked") + private static Map mergeDaily(String hotelCode, Object remote, Map local) { + java.util.LinkedHashMap> byKey = new java.util.LinkedHashMap<>(); + // 先供应方垫底,再本地覆盖(小如库存页维护为准) + appendDailyRows(byKey, remote, false); + appendDailyRows(byKey, local, true); + Map data = new java.util.LinkedHashMap<>(4); + if (StrUtil.isNotBlank(hotelCode)) { + data.put("hotelCode", hotelCode); + } + else if (local != null && local.get("hotelCode") != null) { + data.put("hotelCode", local.get("hotelCode")); + } + else if (remote instanceof Map && ((Map) remote).get("hotelCode") != null) { + data.put("hotelCode", ((Map) remote).get("hotelCode")); + } + data.put("list", new java.util.ArrayList<>(byKey.values())); + return data; + } + + @SuppressWarnings("unchecked") + private static void appendDailyRows(java.util.LinkedHashMap> byKey, Object src, + boolean overwrite) { + if (!(src instanceof Map)) { + return; + } + Object listObj = ((Map) src).get("list"); + if (!(listObj instanceof java.util.Collection)) { + return; + } + for (Object item : (java.util.Collection) listObj) { + if (!(item instanceof Map)) { + continue; + } + Map row = (Map) item; + String productCode = str(row.get("productCode")); + String stayDate = normalizeStayDate(row.get("stayDate")); + if (StrUtil.hasBlank(productCode, stayDate)) { + continue; + } + String key = productCode.trim() + "|" + stayDate; + if (overwrite || !byKey.containsKey(key)) { + Map copy = new java.util.LinkedHashMap<>(row); + copy.put("productCode", productCode.trim()); + copy.put("stayDate", stayDate); + byKey.put(key, copy); + } + } + } + + private static String normalizeStayDate(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof java.util.List) { + java.util.List arr = (java.util.List) raw; + if (arr.size() >= 3) { + try { + int y = Integer.parseInt(String.valueOf(arr.get(0))); + int m = Integer.parseInt(String.valueOf(arr.get(1))); + int d = Integer.parseInt(String.valueOf(arr.get(2))); + return String.format("%04d-%02d-%02d", y, m, d); + } + catch (Exception ignored) { + return null; + } + } + } + String s = String.valueOf(raw).trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return s; + } + private static String str(Object o) { return o == null ? null : String.valueOf(o); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java index 6c80b71..e71b08a 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java @@ -62,7 +62,13 @@ public class HotelZaxDO extends BaseDO { private BigDecimal minSellPrice; - /** ACTIVE / OFFLINE */ + /** + * 上架状态: + * ACTIVE=上架; + * OFFLINE=供应方下架; + * LOCAL_OFFLINE=小如后台手动下架(同步不覆盖); + * OUT_OF_SCOPE=授权范围外(重新授权后可恢复上架) + */ private String status; private String intro; diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java index cb6808a..0cecef8 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java @@ -7,6 +7,7 @@ import cn.hutool.http.HttpRequest; import cn.hutool.http.HttpResponse; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -15,6 +16,9 @@ import java.util.Map; import java.util.TreeMap; import java.util.stream.Collectors; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_SUPPLIER_BIZ_FAIL; + /** * 调用资安心酒店下游开放 API(服务端代签)。 */ @@ -27,10 +31,10 @@ public class ZaxHotelOpenClient { public JSONObject post(String apiPath, Map body) { if (!properties.isEnabled()) { - throw new IllegalStateException("酒店中转未启用,请配置 xrhome.hotel.zax.enabled=true"); + throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店预订服务暂未开启"); } if (StrUtil.hasBlank(properties.getBaseUrl(), properties.getAppKey(), properties.getAppSecret())) { - throw new IllegalStateException("酒店中转缺少 baseUrl/appKey/appSecret 配置"); + throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店预订服务配置不完整"); } String bodyJson = body == null || body.isEmpty() ? "{}" : JSONUtil.toJsonStr(body); String timestamp = String.valueOf(System.currentTimeMillis()); @@ -51,7 +55,8 @@ public class ZaxHotelOpenClient { if (!resp.isOk()) { log.warn("|zax-hotel|HTTP失败|url={}|status={}|body={}|", url, resp.getStatus(), StrUtil.maxLength(raw, 300)); - throw new IllegalStateException("酒店供应方接口异常 HTTP " + resp.getStatus()); + // 业务可读异常,避免被 GlobalExceptionHandler 吞成「系统异常」 + throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店供应方接口异常,请稍后重试"); } JSONObject json = JSONUtil.parseObj(raw); // 资安心 R:code=0 成功 @@ -59,12 +64,21 @@ public class ZaxHotelOpenClient { if (code != null && code != 0) { String msg = StrUtil.blankToDefault(json.getStr("msg"), "酒店供应方业务失败"); log.warn("|zax-hotel|业务失败|url={}|code={}|msg={}|", url, code, StrUtil.maxLength(msg, 200)); - throw new IllegalStateException("供应方下单失败:" + msg); + // 透传供应方文案(库存不足/不可订等),供小程序直接弹出 + throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, msg); } return json; } } + /** 是否供应方业务拒绝(可订校验失败等),便于调用方决定是否刷新本地日历 */ + public static boolean isSupplierBizFail(Throwable e) { + if (e instanceof ServiceException) { + return HOTEL_ZAX_SUPPLIER_BIZ_FAIL.getCode().equals(((ServiceException) e).getCode()); + } + return false; + } + private static String sign(String appKey, String appSecret, String timestamp, String nonce, String bodyJson) { Map params = new TreeMap<>(); params.put("appKey", StrUtil.nullToEmpty(appKey)); diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java index 17c3e73..531b4f3 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java @@ -597,10 +597,15 @@ public class HotelZaxAdminServiceImpl implements HotelZaxAdminService { return hotel; } + /** + * 后台上下架:下架写成 LOCAL_OFFLINE,避免与供应方 OFFLINE、授权外 OUT_OF_SCOPE 混淆; + * 同步不会把 LOCAL_OFFLINE 冲回 ACTIVE。 + */ private static String normalizeStatus(String status) { String s = StrUtil.trim(status); - if ("OFFLINE".equalsIgnoreCase(s)) { - return "OFFLINE"; + if ("OFFLINE".equalsIgnoreCase(s) || "LOCAL_OFFLINE".equalsIgnoreCase(s) + || "OUT_OF_SCOPE".equalsIgnoreCase(s)) { + return "LOCAL_OFFLINE"; } return "ACTIVE"; } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java index fa16ec0..fce6b8d 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java @@ -33,4 +33,10 @@ public interface HotelZaxLocalService { /** 用已查到的日历行报价,规则同 quoteStay。 */ BigDecimal quoteFromDailyRows(List> rows, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms); + + /** + * 供应方判定不可订后,把本地对应夜次标成不可售,避免详情页仍显示可订。 + * 仅改已有日历行;独立于外层事务由调用方用独立事务或 REQUIRES_NEW 包一层更稳妥。 + */ + int markStayUnbookable(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java index e6fc112..4248a75 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java @@ -14,6 +14,8 @@ import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxRoomMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; @@ -89,6 +91,14 @@ public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { if (h == null) { return null; } + // 非上架:返回标记,避免 App 再回源供应方把下架/未授权酒店透出 + if (!isAppOnlineStatus(h.getStatus())) { + Map offline = new LinkedHashMap<>(4); + offline.put("hotelCode", h.getHotelCode()); + offline.put("status", StrUtil.blankToDefault(StrUtil.trim(h.getStatus()), "OFFLINE")); + offline.put("offline", Boolean.TRUE); + return offline; + } Map data = toBrief(h); data.put("carouselImages", h.getCarouselImages()); data.put("intro", h.getIntro()); @@ -148,10 +158,12 @@ public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { row.put("roomTypeCode", d.getRoomTypeCode()); row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString()); row.put("sellPrice", d.getSellPrice()); + row.put("costPrice", d.getCostPrice()); row.put("stock", stock); row.put("sold", sold); row.put("remain", Math.max(0, stock - sold)); - row.put("dailyStatus", d.getDailyStatus()); + // 空状态按开房,避免前端 Number(null)===0 误判售罄 + row.put("dailyStatus", d.getDailyStatus() == null ? 1 : d.getDailyStatus()); list.add(row); } Map data = new LinkedHashMap<>(4); @@ -225,6 +237,50 @@ public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { return nightSum.multiply(new BigDecimal(need)).setScale(2, java.math.RoundingMode.HALF_UP); } + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public int markStayUnbookable(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut) { + if (StrUtil.hasBlank(hotelCode, productCode) || checkIn == null || checkOut == null + || !checkOut.isAfter(checkIn)) { + return 0; + } + HotelZaxDO hotel = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode.trim()) + .last("limit 1")); + if (hotel == null) { + return 0; + } + int updated = 0; + for (LocalDate day = checkIn; day.isBefore(checkOut); day = day.plusDays(1)) { + HotelZaxDailyDO row = dailyMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotel.getId()) + .eq(HotelZaxDailyDO::getProductCode, productCode.trim()) + .eq(HotelZaxDailyDO::getStayDate, day) + .last("limit 1")); + if (row == null) { + continue; + } + int stock = row.getStock() == null ? 0 : row.getStock(); + int sold = row.getSold() == null ? 0 : row.getSold(); + boolean changed = false; + // 无剩余可售:把 sold 顶到 stock,页面 remain=0 + if (stock - sold > 0) { + row.setSold(stock); + changed = true; + } + if (row.getDailyStatus() == null || row.getDailyStatus() != 0) { + // 供应方已拒订,本地先关房,避免仍显示可订 + row.setDailyStatus(0); + changed = true; + } + if (changed) { + dailyMapper.updateById(row); + updated++; + } + } + return updated; + } + private static String stayYmd(Object raw) { if (raw == null) { return null; @@ -326,6 +382,7 @@ public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { row.put("mealType", p.getMealType()); row.put("mealCount", p.getMealCount()); row.put("sellPrice", p.getSellPrice()); + row.put("stock", p.getStock() == null ? 0 : p.getStock()); out.add(row); } return out; @@ -414,6 +471,11 @@ public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { } } + /** App 可见:仅 ACTIVE */ + private static boolean isAppOnlineStatus(String status) { + return "ACTIVE".equalsIgnoreCase(StrUtil.trim(status)); + } + private static String trim(Object o) { return o == null ? null : StrUtil.trim(String.valueOf(o)); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java index 0620f19..d192ffe 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java @@ -23,6 +23,13 @@ public interface HotelZaxSyncService { */ Map syncHotelByCode(String hotelCode); + /** + * 可订失败后强制拉详情+日历。 + *

+ * 使用独立事务:避免外层「下单」事务因校验失败回滚,把刚写入的镜像一并冲掉。 + */ + Map syncHotelByCodeIsolated(String hotelCode); + /** * 标记下架/删除(资源回调 HOTEL_OFFLINE / HOTEL_DELETE)。 */ diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java index f53db81..b4b1bfd 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java @@ -20,6 +20,7 @@ import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -114,6 +115,10 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { syncedCodes.add(code); upsertHotelBrief(row); upsert++; + // 供应方已下架:只落状态,不再拉详情/价态 + if (isOfflineStatus(row.getStr("status"))) { + continue; + } try { DetailPullResult dr = pullDetailResult(code); if (dr.ok) { @@ -167,7 +172,10 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { return out; } - /** 全量同步后,不在本次授权结果集中的本地酒店下架 */ + /** + * 全量同步后:不在本次授权结果集中的本地酒店标为 OUT_OF_SCOPE(非 LOCAL_OFFLINE)。 + * 重新授权后再同步可恢复 ACTIVE,避免「先撤权再授权」一直下架。 + */ private int markOutOfScopeOffline(Set syncedCodes) { List locals = hotelZaxMapper.selectList(new LambdaQueryWrapperX<>()); int n = 0; @@ -178,10 +186,12 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { if (syncedCodes.contains(h.getHotelCode().trim())) { continue; } - if ("OFFLINE".equalsIgnoreCase(h.getStatus())) { + String st = StrUtil.trim(h.getStatus()); + // 后台手动下架、供应方下架、已是范围外:不再改 + if (isLocalOffline(st) || isOfflineStatus(st) || isOutOfScope(st)) { continue; } - h.setStatus("OFFLINE"); + h.setStatus("OUT_OF_SCOPE"); h.setSyncedAt(LocalDateTime.now()); hotelZaxMapper.updateById(h); n++; @@ -192,17 +202,29 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { @Override @Transactional(rollbackFor = Exception.class) public Map syncHotelByCode(String hotelCode) { + return doSyncHotelByCode(hotelCode); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public Map syncHotelByCodeIsolated(String hotelCode) { + return doSyncHotelByCode(hotelCode); + } + + private Map doSyncHotelByCode(String hotelCode) { if (StrUtil.isBlank(hotelCode)) { throw new IllegalArgumentException("hotelCode 不能为空"); } String code = hotelCode.trim(); - boolean ok = pullDetailResult(code).ok; - if (!ok) { + DetailPullResult pull = pullDetailResult(code); + if (!pull.ok) { throw new IllegalStateException("供应方无此酒店详情: " + code); } Map out = new LinkedHashMap<>(4); out.put("hotelCode", code); out.put("ok", true); + out.put("dailyRows", pull.dailyRows); + out.put("dailyFail", pull.dailyFail); return out; } @@ -249,7 +271,7 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { if (minSell != null) { entity.setMinSellPrice(minSell); } - entity.setStatus(StrUtil.blankToDefault(row.getStr("status"), "ACTIVE")); + entity.setStatus(resolveSyncStatus(exist == null ? null : exist.getStatus(), row.getStr("status"))); entity.setSupplierUpdateTime(parseDt(row.getStr("updateTime"))); entity.setSyncedAt(LocalDateTime.now()); if (exist == null) { @@ -260,6 +282,44 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { } } + /** + * 同步状态合并: + * - 供应方 OFFLINE → OFFLINE + * - 本地 LOCAL_OFFLINE(后台手动下架)不被 ACTIVE 冲掉 + * - OUT_OF_SCOPE / 历史误标的 OFFLINE:供应方 ACTIVE 时可恢复上架(重新授权场景) + */ + private static String resolveSyncStatus(String localStatus, String incomingRaw) { + String local = StrUtil.trim(localStatus); + String incoming = StrUtil.trim(incomingRaw); + if (isOfflineStatus(incoming)) { + return "OFFLINE"; + } + if (isLocalOffline(local)) { + return "LOCAL_OFFLINE"; + } + boolean incomingActive = StrUtil.isBlank(incoming) || "ACTIVE".equalsIgnoreCase(incoming); + if (incomingActive) { + // 重新进入授权范围:OUT_OF_SCOPE 恢复;历史撤权曾写成 OFFLINE 的也可恢复 + if (isOutOfScope(local) || isOfflineStatus(local)) { + return "ACTIVE"; + } + return StrUtil.blankToDefault(local, "ACTIVE"); + } + return StrUtil.blankToDefault(local, "ACTIVE"); + } + + private static boolean isOfflineStatus(String status) { + return "OFFLINE".equalsIgnoreCase(StrUtil.trim(status)); + } + + private static boolean isLocalOffline(String status) { + return "LOCAL_OFFLINE".equalsIgnoreCase(StrUtil.trim(status)); + } + + private static boolean isOutOfScope(String status) { + return "OUT_OF_SCOPE".equalsIgnoreCase(StrUtil.trim(status)); + } + private DetailPullResult pullDetailResult(String hotelCode) { DetailPullResult result = new DetailPullResult(); Map req = new HashMap<>(); @@ -299,9 +359,8 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { if (StrUtil.isNotBlank(data.getStr("hotelName"))) { exist.setHotelName(data.getStr("hotelName")); } - if (StrUtil.isNotBlank(data.getStr("status"))) { - exist.setStatus(data.getStr("status")); - } + // 详情同步同样遵守:仅供应方 OFFLINE 可改状态,不把本地下架冲成上架 + exist.setStatus(resolveSyncStatus(exist.getStatus(), data.getStr("status"))); exist.setSyncedAt(LocalDateTime.now()); hotelZaxMapper.updateById(exist); @@ -433,9 +492,18 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { entity.setStock(row.getInt("stock")); Integer status = row.getInt("dailyStatus"); entity.setDailyStatus(status == null ? Integer.valueOf(1) : status); + // 可售以供应方为准:更新时也要同步 sold,否则本地仍显示有房 + Integer sold = row.getInt("sold"); + if (sold != null) { + entity.setSold(sold); + } + else if (exist == null) { + entity.setSold(0); + } + else if (entity.getSold() == null) { + entity.setSold(0); + } if (exist == null) { - Integer sold = row.getInt("sold"); - entity.setSold(sold == null ? Integer.valueOf(0) : sold); dailyMapper.insert(entity); } else { diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java index a98f958..d48f6b7 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java @@ -68,6 +68,8 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService { private final HotelZaxLocalService hotelZaxLocalService; + private final HotelZaxSyncService hotelZaxSyncService; + private final PayOrderApi payOrderApi; private final PayRefundApi payRefundApi; @@ -139,8 +141,14 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService { openBody.put("priceCode", trim(req.get("priceCode"))); } - // 1) 可订校验(不落供应方订单) - openClient.post("order/check", openBody); + // 1) 可订校验(不落供应方订单);失败说明本地镜像落后,独立事务拉齐并关不可售夜次 + try { + openClient.post("order/check", openBody); + } + catch (ServiceException e) { + refreshHotelMirrorAfterCheckFail(hotelCode, productCode, checkIn, checkOut, e.getMessage()); + throw e; + } // 2) 本地待支付单;供应方预定推迟到微信支付成功 HotelZaxOrderDO local = new HotelZaxOrderDO(); @@ -583,6 +591,37 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService { return openBody; } + /** + * 供应方拒订 = 本地酒店信息已落后。 + * 用独立事务拉详情+日历;再把本次入住夜次本地标成不可售,避免「页面可订却一直订不了」。 + */ + private void refreshHotelMirrorAfterCheckFail(String hotelCode, String productCode, LocalDate checkIn, + LocalDate checkOut, String failMsg) { + if (StrUtil.isBlank(hotelCode)) { + return; + } + // failMsg 仅用于日志;供应方拒订一律拉齐,避免镜像漂移导致「页面可订却订不了」 + log.info("|hotel-order|可订失败准备刷新镜像|hotelCode={}|product={}|msg={}|", hotelCode, productCode, + StrUtil.maxLength(StrUtil.nullToEmpty(failMsg), 120)); + try { + Map sync = hotelZaxSyncService.syncHotelByCodeIsolated(hotelCode); + log.info("|hotel-order|可订失败已独立事务刷新酒店镜像|hotelCode={}|dailyRows={}|dailyFail={}|", + hotelCode, sync == null ? null : sync.get("dailyRows"), + sync == null ? null : sync.get("dailyFail")); + } + catch (Exception ex) { + log.warn("|hotel-order|可订失败后刷新本地镜像失败|hotelCode={}|{}", hotelCode, ex.getMessage()); + } + try { + int n = hotelZaxLocalService.markStayUnbookable(hotelCode, productCode, checkIn, checkOut); + log.info("|hotel-order|可订失败已标记本地不可售夜次|hotelCode={}|product={}|nights={}|", hotelCode, + productCode, n); + } + catch (Exception ex) { + log.warn("|hotel-order|可订失败后标记本地不可售失败|hotelCode={}|{}", hotelCode, ex.getMessage()); + } + } + private BigDecimal quoteStayAmount(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms) { BigDecimal local = hotelZaxLocalService.quoteStay(hotelCode, productCode, checkIn, checkOut, rooms);