From 069f2f4b09a797371a7cfea3e4cf505630cb8f17 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 16 Oct 2026 17:26:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=9E=E8=B0=83=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E9=97=AE=E9=A2=98=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=85=A7?= =?UTF-8?q?=E8=AE=A2=E4=B8=9A=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constant/SmHotelDownstreamProtocol.java | 24 +- .../constant/SmHotelDownstreamPushKind.java | 32 + .../SmHotelDownstreamChannelConfigDTO.java | 26 +- .../config/HotelDownstreamProperties.java | 53 +- .../SmHotelCallbackTaskController.java | 23 + .../controller/SmHotelController.java | 3 + .../SmHotelHuidingOrderController.java | 127 ++++ .../downstream/DownstreamHotelClient.java | 12 + .../downstream/DownstreamInboundAuth.java | 81 +++ .../huiding/HuidingDownstreamApi.java | 25 + .../HuidingDownstreamHotelAdapter.java | 237 +++++++ .../downstream/huiding/HuidingSignHelper.java | 61 ++ ...SmHotelDownstreamResourcePushSchedule.java | 29 + .../mapper/SmHotelDailyMapper.java | 7 + .../service/SmHotelCallbackTaskService.java | 12 + .../service/SmHotelChannelConfigService.java | 3 + .../SmHotelDownstreamAutoPushService.java | 22 + .../service/SmHotelHuidingOrderService.java | 30 + .../service/SmHotelMidService.java | 7 + .../impl/SmHotelCallbackTaskServiceImpl.java | 102 ++- .../impl/SmHotelChannelConfigServiceImpl.java | 32 + .../SmHotelDownstreamAutoPushServiceImpl.java | 238 +++++++ .../impl/SmHotelHuidingOrderServiceImpl.java | 600 ++++++++++++++++++ .../service/impl/SmHotelMidServiceImpl.java | 557 +++++++++++++++- .../service/impl/SmHotelServiceImpl.java | 21 +- .../service/impl/SmHotelSyncServiceImpl.java | 2 +- .../supermarket/util/HotelAreaAdcodeUtil.java | 37 ++ .../src/main/resources/application.yml | 5 + 28 files changed, 2368 insertions(+), 40 deletions(-) create mode 100644 pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamPushKind.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamApi.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelDownstreamResourcePushSchedule.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAutoPushService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAutoPushServiceImpl.java create mode 100644 pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java index e78b87fc..68382456 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamProtocol.java @@ -11,13 +11,20 @@ import java.util.Set; */ public final class SmHotelDownstreamProtocol { - /** 环启:docs/xiayou 目录下《第三方数据同步接口文档》 */ + /** 环启:docs/xiayou 第三方数据同步 */ public static final String HUANQI = "huanqi"; + /** 慧订:docs/xiayou/shenzhen(静态 + hoteldatepush) */ + public static final String HUIDING = "huiding"; + /** @deprecated 历史误用名,normalize 后等价 {@link #HUANQI} */ @Deprecated public static final String XIAYOU = "xiayou"; + /** @deprecated 文档目录名,normalize 后等价 {@link #HUIDING} */ + @Deprecated + public static final String SHENZHEN = "shenzhen"; + private SmHotelDownstreamProtocol() { } @@ -26,6 +33,9 @@ public final class SmHotelDownstreamProtocol { if (p.isEmpty() || XIAYOU.equals(p)) { return HUANQI; } + if (SHENZHEN.equals(p)) { + return HUIDING; + } return p; } @@ -33,6 +43,7 @@ public final class SmHotelDownstreamProtocol { public static Set supported() { Set set = new LinkedHashSet<>(); set.add(HUANQI); + set.add(HUIDING); return set; } @@ -40,11 +51,22 @@ public final class SmHotelDownstreamProtocol { return supported().contains(normalize(protocol)); } + public static boolean isHuanqi(String protocol) { + return HUANQI.equals(normalize(protocol)); + } + + public static boolean isHuiding(String protocol) { + return HUIDING.equals(normalize(protocol)); + } + public static String label(String protocol) { String p = normalize(protocol); if (HUANQI.equals(p)) { return "环启"; } + if (HUIDING.equals(p)) { + return "慧订"; + } return p; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamPushKind.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamPushKind.java new file mode 100644 index 00000000..3c03f41d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/constant/SmHotelDownstreamPushKind.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.supermarket.constant; + +/** + * 下游资源推送分类(环启等):可售推 / 关房推 / 纯跳过。 + * 见 docs/xiayou/环启下游对接总览.md §5.1 + */ +public final class SmHotelDownstreamPushKind { + + /** 上架且有产品且窗口内有日历 → 正常推本地价态 */ + public static final String SELL = "SELL"; + + /** 仍有产品,但已下架或窗口内无日历 → 推窗口内 dailyStatus=0,清对方旧价 */ + public static final String CLOSE = "CLOSE"; + + /** 本地已无产品 → 无法组关房报文,只记跳过 */ + public static final String SKIP = "SKIP"; + + private SmHotelDownstreamPushKind() { + } + + public static boolean isSell(String kind) { + return SELL.equals(kind); + } + + public static boolean isClose(String kind) { + return CLOSE.equals(kind); + } + + public static boolean isSkip(String kind) { + return SKIP.equals(kind); + } +} 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 aa8640a1..9c422201 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 @@ -17,12 +17,33 @@ public class SmHotelDownstreamChannelConfigDTO { @Schema(description = "下游 B 系统根地址,如 http://host/prod-api") private String baseUrl; - @Schema(description = "B 系统分配的 appKey") + @Schema(description = "B 系统分配的 appKey(环启;慧订可作 key 回落)") private String appKey; @Schema(description = "B 系统分配的 appSecret(仅本地签名)") private String appSecret; + @Schema(description = "慧订资源接口 key;未填则用 appKey") + private String accessKey; + + @Schema(description = "慧订 key 别名(YAML 写 key: 亦可)") + private String key; + + @Schema(description = "慧订供应商 ID(supplierId)") + private Integer supplierId; + + @Schema(description = "慧订订单入站 appId") + private String orderAppId; + + @Schema(description = "慧订订单 HMAC 密钥") + private String orderAppSecret; + + @Schema(description = "慧订订单通知 pass") + private String orderNotifyPass; + + @Schema(description = "慧订查单路径,默认 /splider/queryOrder") + private String orderQueryPath; + @Schema(description = "HTTP 超时毫秒") private Integer timeoutMs; @@ -70,4 +91,7 @@ public class SmHotelDownstreamChannelConfigDTO { @Schema(description = "推送流水保留天数,默认 15;≤0 不清理") private Integer pushLogRetainDays; + + @Schema(description = "是否参与资源日更定时推送;false=仅手动/变更推,默认 false") + private Boolean resourcePushJoinSchedule; } 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 0e2c2f42..430d54cd 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 @@ -25,12 +25,40 @@ public class HotelDownstreamProperties { /** 下游 B 系统根地址,例 https://b.example.com ,实际请求 = baseUrl + /third/party/api/hotel/sync */ private String baseUrl = ""; - /** B 系统分配给我们的 appKey */ + /** B 系统分配给我们的 appKey(环启签名用;慧订亦可填对方 key,与 accessKey 二选一) */ private String appKey = ""; - /** B 系统分配给我们的 appSecret(仅本地签名,不落报文) */ + /** B 系统分配给我们的 appSecret(仅本地签名,不落报文);慧订资源推送可不填 */ private String appSecret = ""; + /** + * 慧订资源接口密钥(请求体 key)。未配时回落 appKey。 + */ + private String accessKey = ""; + + /** 慧订供应商 ID(请求体 supplierId) */ + private Integer supplierId; + + /** + * 慧订订单入站 appId(HMAC);未配时回落 appKey。 + */ + private String orderAppId = ""; + + /** + * 慧订订单 HMAC 密钥(文档 secret/pass);未配时回落 appSecret。 + */ + private String orderAppSecret = ""; + + /** + * 慧订「订单通知」简单密钥(请求体 pass);未配时回落 accessKey/appKey。 + */ + private String orderNotifyPass = ""; + + /** + * 慧订订单查询路径(我方→对方)。文档仓库未齐时可配,默认 /splider/queryOrder。 + */ + private String orderQueryPath = "/splider/queryOrder"; + private int timeoutMs = 30000; /** 拉取上游后自动生成未来日历天数(默认 90≈3个月,与环启可改窗口取交集) */ @@ -93,4 +121,25 @@ public class HotelDownstreamProperties { /** 推送流水保留天数,定时清理更早数据;≤0 不自动清理 */ private int pushLogRetainDays = 15; + + /** + * 本地/回调变更后是否自动推下游(防抖)。 + * false:只靠手动推与日更。 + */ + private boolean resourceAutoPushEnabled = true; + + /** 变更后防抖秒数(同酒店多次变更合并为一次推送) */ + private int resourceAutoPushDebounceSeconds = 90; + + /** + * 是否参与资源日更定时推送(授权范围全量:可售/关房/跳过)。 + * false(默认):不进日更;确认范围与限流后再在伙伴 YAML 开 true。 + */ + private boolean resourcePushJoinSchedule = false; + + /** 进程级:是否跑资源日更时钟;改 cron 需重启 */ + private boolean resourcePushScheduleEnabled = true; + + /** 资源日更 cron(默认每天 02:30) */ + private String resourcePushCron = "0 30 2 * * ?"; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java index f4762aed..5e9909b8 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java @@ -51,6 +51,29 @@ public class SmHotelCallbackTaskController { return callbackTaskService.retry(id); } + @SysLog("酒店回调失败任务批量重试") + @Operation(summary = "按筛选条件批量重试失败任务(默认最多 500 条)") + @PostMapping("/retryFailed") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')") + public R retryFailed(@RequestParam(required = false) String notifyType, + @RequestParam(required = false) String hotelCode, + @RequestParam(required = false) String hotelName, + @RequestParam(required = false) String createTimeBegin, + @RequestParam(required = false) String createTimeEnd, + @RequestParam(required = false) Boolean includeSkipped, + @RequestParam(required = false) Integer limit) { + return callbackTaskService.retryFailed(notifyType, hotelCode, hotelName, createTimeBegin, createTimeEnd, + includeSkipped, limit); + } + + @SysLog("酒店回调任务按ID批量重试") + @Operation(summary = "按任务 ID 列表批量重试") + @PostMapping("/retryBatch") + @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')") + public R retryBatch(@RequestBody Long[] ids) { + return callbackTaskService.retryBatch(ids); + } + @SysLog("酒店回调待处理任务批量重新入队") @Operation(summary = "把滞留 PENDING(及超时 RUNNING)重新推入 Redis 队列") @PostMapping("/requeuePending") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java index a21c99ab..fcc80bdd 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.service.SmHotelService; +import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil; import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -40,6 +41,8 @@ public class SmHotelController { .eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName()) .orderByDesc(SmHotelEntity::getCreateTime) .orderByDesc(SmHotelEntity::getId); + // 位置区域:省/市前缀覆盖下级,并兼容仅有区域名称的老数据 + HotelAreaAdcodeUtil.applyHotelListFilter(wrapper, query.getDistrictAdcode(), query.getDistrict()); // 上架(ACTIVE):含未写状态的老数据;下架:仅 OFFLINE;空=全部 if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) { wrapper.eq(SmHotelEntity::getUpstreamStatus, "OFFLINE"); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java new file mode 100644 index 00000000..6bf6502b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelHuidingOrderController.java @@ -0,0 +1,127 @@ +package com.bsd.pjcloud.supermarket.controller; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth; +import com.bsd.pjcloud.supermarket.service.SmHotelHuidingOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 慧订订单入站:校验/创建/取消(HMAC)+ 订单通知(pass)。 + *
+ * POST /third/party/huiding/order/check
+ * POST /third/party/huiding/order/create
+ * POST /third/party/huiding/order/cancel
+ * POST /third/party/huiding/order/notify
+ * 
+ */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/third/party/huiding/order") +@Tag(name = "慧订酒店订单对接") +public class SmHotelHuidingOrderController { + + private final DownstreamInboundAuth inboundAuth; + + private final SmHotelHuidingOrderService huidingOrderService; + + @Inner(value = false) + @Operation(summary = "慧订下单前校验") + @PostMapping("/check") + public Map check(@RequestBody String rawBody) { + try { + JSONObject envelope = JSONUtil.parseObj(rawBody); + String partner = inboundAuth.verifyForHuiding(envelope); + JSONObject data = parseDataJson(envelope); + return huidingOrderService.check(partner, data); + } + catch (Exception e) { + log.warn("|慧订订单预检|失败|{}", e.getMessage()); + return err500(e.getMessage()); + } + } + + @Inner(value = false) + @Operation(summary = "慧订创建订单") + @PostMapping("/create") + public Map create(@RequestBody String rawBody) { + try { + JSONObject envelope = JSONUtil.parseObj(rawBody); + String partner = inboundAuth.verifyForHuiding(envelope); + JSONObject data = parseDataJson(envelope); + return huidingOrderService.create(partner, data); + } + catch (Exception e) { + log.warn("|慧订创建订单|失败|{}", e.getMessage()); + return err500(e.getMessage()); + } + } + + @Inner(value = false) + @Operation(summary = "慧订取消订单") + @PostMapping("/cancel") + public Map cancel(@RequestBody String rawBody) { + try { + JSONObject envelope = JSONUtil.parseObj(rawBody); + String partner = inboundAuth.verifyForHuiding(envelope); + JSONObject data = parseDataJson(envelope); + return huidingOrderService.cancel(partner, data); + } + catch (Exception e) { + log.warn("|慧订取消订单|失败|{}", e.getMessage()); + return err500(e.getMessage()); + } + } + + @Inner(value = false) + @Operation(summary = "慧订订单通知(再查单/履约)") + @PostMapping("/notify") + public Map notify(@RequestBody String rawBody) { + try { + JSONObject body = JSONUtil.parseObj(rawBody); + String pass = StrUtil.trim(body.getStr("pass")); + String ordercode = StrUtil.trim(body.getStr("ordercode")); + String childcode = StrUtil.trim(body.getStr("childcode")); + return huidingOrderService.notify(pass, ordercode, childcode); + } + catch (Exception e) { + log.warn("|慧订订单通知|失败|{}", e.getMessage()); + Map resp = new LinkedHashMap<>(4); + resp.put("code", 1); + resp.put("message", StrUtil.blankToDefault(e.getMessage(), "失败")); + return resp; + } + } + + private static JSONObject parseDataJson(JSONObject envelope) { + Object data = envelope.get("data"); + if (data instanceof JSONObject jo) { + return jo; + } + String s = data == null ? null : String.valueOf(data); + if (StrUtil.isBlank(s)) { + throw new IllegalArgumentException("data 为空"); + } + return JSONUtil.parseObj(s); + } + + private static Map err500(String msg) { + Map m = new LinkedHashMap<>(4); + m.put("code", 500); + m.put("msg", StrUtil.blankToDefault(msg, "失败")); + return m; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java index 138b0cb9..69058322 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamHotelClient.java @@ -58,6 +58,18 @@ public class DownstreamHotelClient { return adapter(props).orderDetail(partnerCode, props, body); } + /** + * 慧订订单状态回写;非慧订协议抛异常。 + */ + public JSONObject huidingOperationOrder(String partnerCode, Object body) { + HotelDownstreamProperties props = resolve(partnerCode); + DownstreamHotelAdapter ad = adapter(props); + if (!(ad instanceof com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingDownstreamHotelAdapter huiding)) { + throw new IllegalStateException("伙伴协议不是慧订,无法 operationOrder: " + partnerCode); + } + return huiding.operationOrder(partnerCode, props, body); + } + private DownstreamHotelAdapter adapter(HotelDownstreamProperties props) { String protocol = props == null ? SmHotelDownstreamProtocol.HUANQI : props.getProtocol(); return adapterRegistry.require(protocol); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java index b331e313..81074856 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAuth.java @@ -4,13 +4,20 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.bsd.pjcloud.common.core.exception.CheckedException; import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity; +import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingSignHelper; import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper; import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; /** @@ -22,6 +29,10 @@ public class DownstreamInboundAuth { private static final String NONCE_KEY = "canteen:hotel:downstream:nonce:"; + private static final String HUIDING_NONCE_KEY = "sm:hotel:huiding:nonce:"; + + private static final DateTimeFormatter HUIDING_TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final SmHotelDownstreamAppMapper appMapper; private final StringRedisTemplate redisTemplate; @@ -105,4 +116,74 @@ public class DownstreamInboundAuth { throw new CheckedException("签名错误"); } } + + /** + * 慧订订单入站验签(HMAC-SHA256)。返回匹配的伙伴编码。 + * @param envelope 外层字段:appId/nonce/signType/timeStamp/sign/data + */ + public String verifyForHuiding(Map envelope) { + if (envelope == null || envelope.isEmpty()) { + throw new CheckedException("报文为空"); + } + String appId = StrUtil.trim(str(envelope.get("appId"))); + String nonce = StrUtil.trim(str(envelope.get("nonce"))); + String signType = StrUtil.trim(str(envelope.get("signType"))); + String timeStamp = StrUtil.trim(str(envelope.get("timeStamp"))); + String sign = StrUtil.trim(str(envelope.get("sign"))); + String data = envelope.get("data") == null ? null : String.valueOf(envelope.get("data")); + if (StrUtil.isBlank(appId) || StrUtil.isBlank(nonce) || StrUtil.isBlank(timeStamp) || StrUtil.isBlank(sign) + || StrUtil.isBlank(data)) { + throw new CheckedException("缺少鉴权参数或 data"); + } + if (StrUtil.isNotBlank(signType) && !HuidingSignHelper.SIGN_TYPE_HMAC.equalsIgnoreCase(signType)) { + throw new CheckedException("signType 仅支持 HMAC-SHA256"); + } + LocalDateTime ts; + try { + ts = LocalDateTime.parse(timeStamp, HUIDING_TS); + } + catch (Exception e) { + throw new CheckedException("timeStamp 格式须为 yyyy-MM-dd HH:mm:ss"); + } + if (Math.abs(ChronoUnit.SECONDS.between(ts, LocalDateTime.now())) > 5 * 60L) { + throw new CheckedException("签名过期"); + } + Boolean ok = redisTemplate.opsForValue().setIfAbsent(HUIDING_NONCE_KEY + appId + ":" + nonce, "1", 5, + TimeUnit.MINUTES); + if (Boolean.FALSE.equals(ok)) { + throw new CheckedException("nonce 重复"); + } + String partner = null; + String secret = null; + for (String code : channelConfigService.listEnabledDownstreamPartners()) { + HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code); + if (p == null || !SmHotelDownstreamProtocol.isHuiding(p.getProtocol())) { + continue; + } + String cfgAppId = StrUtil.blankToDefault(StrUtil.trim(p.getOrderAppId()), StrUtil.trim(p.getAppKey())); + if (appId.equals(cfgAppId)) { + secret = StrUtil.blankToDefault(StrUtil.trim(p.getOrderAppSecret()), StrUtil.trim(p.getAppSecret())); + partner = code; + break; + } + } + if (StrUtil.isBlank(secret) || StrUtil.isBlank(partner)) { + throw new CheckedException("appId 未匹配到启用的慧订伙伴"); + } + Map signMap = new LinkedHashMap<>(); + signMap.put("appId", appId); + signMap.put("data", data); + signMap.put("nonce", nonce); + signMap.put("signType", StrUtil.blankToDefault(signType, HuidingSignHelper.SIGN_TYPE_HMAC)); + signMap.put("timeStamp", timeStamp); + String expect = HuidingSignHelper.sign(signMap, secret); + if (!expect.equalsIgnoreCase(sign)) { + throw new CheckedException("签名错误"); + } + return partner; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamApi.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamApi.java new file mode 100644 index 00000000..f14e8106 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamApi.java @@ -0,0 +1,25 @@ +package com.bsd.pjcloud.supermarket.integration.downstream.huiding; + +/** + * 慧订(docs/xiayou/shenzhen)资源推送路径。 + */ +public final class HuidingDownstreamApi { + + /** 推送静态酒店+房型 */ + public static final String PUSH_STATIC_HOTELS = "/splider/pushStaticHotels"; + + /** 推送价态(窗口全量覆盖) */ + public static final String HOTEL_DATE_PUSH = "/splider/hoteldatepush"; + + /** 核对已匹配酒店列表 */ + public static final String HOTELS = "/splider/hotels"; + + /** 订单状态回写(确认/拒绝/取消确认) */ + public static final String OPERATION_ORDER = "/splider/operationOrder"; + + /** 订单查询默认路径(文档未齐时可在 YAML order-query-path 覆盖) */ + public static final String QUERY_ORDER_DEFAULT = "/splider/queryOrder"; + + private HuidingDownstreamApi() { + } +} 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 new file mode 100644 index 00000000..624a7a77 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingDownstreamHotelAdapter.java @@ -0,0 +1,237 @@ +package com.bsd.pjcloud.supermarket.integration.downstream.huiding; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.bsd.pjcloud.common.core.exception.CheckedException; +import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelAdapter; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogContext; +import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 慧订下游:静态 pushStaticHotels + 价态 hoteldatepush(窗口全量覆盖)。 + * 请求体带 key / supplierId,成功码为 0(与环启 200 不同)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class HuidingDownstreamHotelAdapter implements DownstreamHotelAdapter { + + private static final String TAG = "下游推送[慧订]"; + + private final SmHotelDownstreamPushLogService pushLogService; + + @Override + public String protocol() { + return SmHotelDownstreamProtocol.HUIDING; + } + + @Override + public void assertReady(String partnerCode, HotelDownstreamProperties props) { + if (props == null || !props.isEnabled()) { + throw new CheckedException("下游伙伴未启用: " + partnerCode); + } + if (StrUtil.isBlank(props.getBaseUrl())) { + throw new CheckedException("慧订配置缺少 base-url: " + partnerCode); + } + if (StrUtil.isBlank(resolveKey(props))) { + throw new CheckedException("慧订配置缺少 key(access-key 或 app-key): " + partnerCode); + } + if (props.getSupplierId() == null) { + throw new CheckedException("慧订配置缺少 supplier-id: " + partnerCode); + } + } + + /** Mid 传入已组好的完整请求体(含 key/supplierId/data),此处原样 POST */ + @Override + public JSONObject hotelSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + Object body) { + return post(partnerCode, props, hotelId, extHotelId, "static_sync", HuidingDownstreamApi.PUSH_STATIC_HOTELS, + body); + } + + /** 慧订无独立房型接口;Mid 不应调用 */ + @Override + public JSONObject apartmentSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + Object body) { + return okSkip("apartment_sync skipped for huiding"); + } + + /** 慧订无独立产品接口;Mid 不应调用 */ + @Override + public JSONObject productSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + Object body) { + return okSkip("product_sync skipped for huiding"); + } + + /** Mid 传入 hoteldatepush 完整请求体 */ + @Override + public JSONObject dailySync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + Object body) { + return post(partnerCode, props, hotelId, extHotelId, "date_push", HuidingDownstreamApi.HOTEL_DATE_PUSH, body); + } + + /** 订单状态回写 operationOrder */ + public JSONObject operationOrder(String partnerCode, HotelDownstreamProperties props, Object body) { + return post(partnerCode, props, null, null, "operation_order", HuidingDownstreamApi.OPERATION_ORDER, body); + } + + /** 订单查询(路径可配置) */ + @Override + public JSONObject orderQuery(String partnerCode, HotelDownstreamProperties props, Object body) { + String path = StrUtil.blankToDefault(props == null ? null : props.getOrderQueryPath(), + HuidingDownstreamApi.QUERY_ORDER_DEFAULT); + if (!path.startsWith("/")) { + path = "/" + path; + } + return post(partnerCode, props, null, null, "order_query", path, body); + } + + private static JSONObject okSkip(String msg) { + JSONObject o = new JSONObject(); + o.set("code", 0); + o.set("message", msg); + return o; + } + + private JSONObject post(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + String action, String path, Object bodyObj) { + assertReady(partnerCode, props); + String bodyJson = bodyObj instanceof String ? (String) bodyObj : JSONUtil.toJsonStr(bodyObj); + String url = trimSlash(props.getBaseUrl()) + path; + long t0 = System.currentTimeMillis(); + Integer httpStatus = null; + String respBody = null; + String errMsg = null; + boolean ok = false; + JSONObject json = null; + try { + int timeout = props.getTimeoutMs() > 0 ? props.getTimeoutMs() : 30000; + HttpResponse resp = HttpRequest.post(url) + .header("Content-Type", "application/json;charset=UTF-8") + .body(bodyJson) + .timeout(timeout) + .execute(); + httpStatus = resp.getStatus(); + respBody = resp.body(); + String trimmed = StrUtil.trim(respBody); + if (StrUtil.isBlank(trimmed)) { + errMsg = "慧订响应为空: " + path; + } + else if (trimmed.startsWith("<")) { + errMsg = StrUtil.format("慧订返回 HTML [{}] http={} body={}", path, httpStatus, + StrUtil.maxLength(trimmed, 180)); + } + else { + try { + json = JSONUtil.parseObj(trimmed); + } + catch (Exception parseEx) { + errMsg = StrUtil.format("慧订响应非 JSON [{}] http={} body={}", path, httpStatus, + StrUtil.maxLength(trimmed, 180)); + } + } + if (json != null) { + // 慧订:code=0 成功;兼容部分接口用 200 + int bizCode = json.getInt("code", -1); + if (bizCode == 0 || bizCode == 200) { + ok = true; + } + else { + errMsg = StrUtil.format("慧订失败[{}] code={} msg={}", path, bizCode, + StrUtil.blankToDefault(json.getStr("message"), json.getStr("msg"))); + } + } + if (!ok) { + throw new CheckedException(StrUtil.blankToDefault(errMsg, "慧订调用失败")); + } + return json; + } + catch (CheckedException e) { + throw e; + } + catch (Exception e) { + errMsg = "慧订调用失败: " + e.getMessage(); + throw new CheckedException(errMsg, e); + } + finally { + recordLog(partnerCode, props, hotelId, extHotelId, action, url, bodyJson, httpStatus, respBody, ok, errMsg, + System.currentTimeMillis() - t0); + } + } + + private void recordLog(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId, + String action, String url, String reqBody, Integer httpStatus, String respBody, boolean ok, String errMsg, + long costMs) { + try { + String mode = StrUtil.blankToDefault(props.getPushLogMode(), "fail_only").toLowerCase(); + boolean should; + if (!ok || "all".equals(mode)) { + should = true; + } + else if ("sample".equals(mode)) { + should = DownstreamPushLogContext.tryConsumeSuccessSample(); + } + else { + // fail_only:成功不落库 + should = false; + } + if (!should) { + return; + } + int maxChars = props.getPushLogBodyMaxChars(); + SmHotelDownstreamPushLogEntity row = new SmHotelDownstreamPushLogEntity(); + row.setPartnerCode(partnerCode); + row.setSyncLogId(DownstreamPushLogContext.syncLogId()); + row.setHotelId(hotelId); + row.setExtHotelId(extHotelId); + row.setAction(action); + row.setRequestUrl(url); + row.setHttpStatus(httpStatus); + row.setSuccess(ok ? "1" : "0"); + row.setErrorMsg(StrUtil.maxLength(errMsg, 900)); + row.setCostMs(costMs); + if (maxChars > 0) { + row.setRequestBody(StrUtil.maxLength(reqBody, maxChars)); + row.setResponseBody(StrUtil.maxLength(respBody, maxChars)); + } + else { + row.setRequestBody(reqBody); + row.setResponseBody(respBody); + } + pushLogService.record(row); + } + catch (Exception e) { + log.warn("|{}|写推送日志失败|{}", TAG, e.getMessage()); + } + } + + public static String resolveKey(HotelDownstreamProperties props) { + if (props == null) { + return ""; + } + if (StrUtil.isNotBlank(props.getAccessKey())) { + return props.getAccessKey().trim(); + } + return StrUtil.trimToEmpty(props.getAppKey()); + } + + private static String trimSlash(String base) { + if (base == null) { + return ""; + } + String b = base.trim(); + while (b.endsWith("/")) { + b = b.substring(0, b.length() - 1); + } + return b; + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java new file mode 100644 index 00000000..91c536cf --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/huiding/HuidingSignHelper.java @@ -0,0 +1,61 @@ +package com.bsd.pjcloud.supermarket.integration.downstream.huiding; + +import cn.hutool.core.util.StrUtil; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; + +/** + * 慧订订单入站签名(docs/xiayou/shenzhen/java示例签名.md):HMAC-SHA256 大写十六进制。 + */ +public final class HuidingSignHelper { + + public static final String SIGN_TYPE_HMAC = "HMAC-SHA256"; + + private HuidingSignHelper() { + } + + /** + * @param data 参与签名的字段(不含 sign;空值跳过);最后拼接 secret=密钥 + */ + public static String sign(Map data, String secret) { + if (data == null || StrUtil.isBlank(secret)) { + throw new IllegalArgumentException("签名参数不完整"); + } + String[] keys = data.keySet().toArray(new String[0]); + Arrays.sort(keys); + StringBuilder sb = new StringBuilder(); + for (String k : keys) { + if ("sign".equals(k)) { + continue; + } + Object raw = data.get(k); + String value = raw == null ? null : String.valueOf(raw).trim(); + if (StrUtil.isBlank(value)) { + continue; + } + sb.append(k).append("=").append(value).append("&"); + } + sb.append("secret=").append(secret); + return hmacSha256HexUpper(sb.toString(), secret); + } + + public static String hmacSha256HexUpper(String content, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] array = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(array.length * 2); + for (byte item : array) { + sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3)); + } + return sb.toString().toUpperCase(); + } + catch (Exception e) { + throw new IllegalStateException("HMAC-SHA256 失败: " + e.getMessage(), e); + } + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelDownstreamResourcePushSchedule.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelDownstreamResourcePushSchedule.java new file mode 100644 index 00000000..da2104f4 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelDownstreamResourcePushSchedule.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.supermarket.job; + +import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 下游资源日更:到点后对 resource-push-join-schedule=true 的伙伴做授权范围全量推 + *(可售推 / 关房推 / 纯跳过由 pushToDownstream 内部分类)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SmHotelDownstreamResourcePushSchedule { + + private final SmHotelDownstreamAutoPushService autoPushService; + + @Scheduled(cron = "${hotel-downstream.resource-push-cron:0 30 2 * * ?}") + public void scheduledScopePush() { + try { + autoPushService.runScheduledScopePush(); + } + catch (Exception e) { + log.error("|下游资源日更|异常", e); + } + } +} 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 83e029d6..6ec6fa12 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 @@ -23,6 +23,13 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper= #{fromDate}") int physicalDeleteFromDate(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate); + /** + * 按入住日半开区间物理删除,供回调/区间拉价「先删再插」使用(可含今天之前的日期)。 + */ + @Delete("DELETE FROM canteen_hotel_daily WHERE hotel_id = #{hotelId} AND stay_date >= #{fromDate} AND stay_date < #{toExclusive}") + int physicalDeleteRange(@Param("hotelId") Long hotelId, @Param("fromDate") LocalDate fromDate, + @Param("toExclusive") LocalDate toExclusive); + int insertBatch(@Param("list") List list); /** diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java index e0af088d..2f8918f3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelCallbackTaskService.java @@ -26,6 +26,18 @@ public interface SmHotelCallbackTaskService { /** 失败/跳过任务重新入队 */ R retry(Long id); + /** + * 按当前筛选条件批量重试失败任务(默认只重试 FAILED;可含 SKIPPED)。 + * @return 成功入队条数 + */ + R retryFailed(String notifyType, String hotelCode, String hotelName, String createTimeBegin, + String createTimeEnd, Boolean includeSkipped, Integer limit); + + /** + * 按任务 ID 列表批量重试(跳过 RUNNING;单次上限 500)。 + */ + R retryBatch(Long[] ids); + /** 删除单条回调任务 */ R removeTask(Long id); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelChannelConfigService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelChannelConfigService.java index 178193ff..1bf1f5b3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelChannelConfigService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelChannelConfigService.java @@ -38,6 +38,9 @@ public interface SmHotelChannelConfigService extends IService listEnabledDownstreamPartners(); + /** 参与资源日更定时推送的启用伙伴(resource-push-join-schedule=true) */ + List listResourcePushSchedulePartners(); + /** 渠道是否在库表中启用(无行时回落 YAML enabled;默认按上游) */ boolean isChannelEnabled(String channel); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAutoPushService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAutoPushService.java new file mode 100644 index 00000000..6431df7b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamAutoPushService.java @@ -0,0 +1,22 @@ +package com.bsd.pjcloud.supermarket.service; + +/** + * 下游资源自动推送:本地/回调变更后防抖推;日更按授权范围扫。 + */ +public interface SmHotelDownstreamAutoPushService { + + /** + * 酒店本地或上游拉取已更新后调用:事务提交后防抖,再向命中范围的下游伙伴推送。 + */ + void scheduleAfterHotelChanged(Long hotelId); + + /** + * 删产品前:对指定产品先向下游关房(避免删光后无法组关房报文)。 + */ + void closeProductsOnMatchedPartners(Long hotelId, String productCode, String roomTypeCode); + + /** + * 日更:对 resource-push-join-schedule=true 的启用伙伴,启动范围全量推(可售/关房/跳过由 push 内部分类)。 + */ + void runScheduledScopePush(); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java new file mode 100644 index 00000000..270045b4 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelHuidingOrderService.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.supermarket.service; + +import cn.hutool.json.JSONObject; +import com.bsd.pjcloud.common.core.util.R; + +import java.util.Map; + +/** + * 慧订订单:校验/创建/取消/通知/查单回写。 + */ +public interface SmHotelHuidingOrderService { + + /** 下单前校验 → 慧订 data.code 形态 */ + Map check(String partnerCode, JSONObject data); + + /** 创建订单 */ + Map create(String partnerCode, JSONObject data); + + /** 取消订单 */ + Map cancel(String partnerCode, JSONObject data); + + /** + * 订单通知:pass 鉴权后按 childcode 查本地或拉对方查单再履约。 + * 外层返回慧订通知形态 code 0/1。 + */ + Map notify(String pass, String ordercode, String childcode); + + /** 主动查对方订单(联调/排障) */ + R queryRemote(String partnerCode, String ordercode, String childcode); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java index ee123b38..b0357dc3 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java @@ -42,10 +42,17 @@ public interface SmHotelMidService { /** * 将本地酒店+房型+产品+日历推送给下游销售方。 + * 内部分类:可售推 / 关房推 / 纯跳过(见 SmHotelDownstreamPushKind)。 * @param partnerCode 指定下游伙伴编码;必填,仅推该伙伴(且须在范围映射内) */ R> pushToDownstream(Long hotelId, String partnerCode); + /** + * 对指定产品推下游窗口内关房日历(删产品前调用,避免删后无法组报文)。 + */ + R> closeProductDailyOnDownstream(Long hotelId, String partnerCode, String productCode, + String roomTypeCode); + R> pullAndPush(Long hotelId, String partnerCode); R> pullAndPushBatch(List hotelIds, String partnerCode); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java index dfb3e07a..50a9ea75 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelCallbackTaskServiceImpl.java @@ -135,17 +135,107 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic @Override public R retry(Long id) { + if (!props().isCallbackQueueEnabled()) { + return R.failed("回调队列未启用(callback-queue-enabled=false),请先在渠道对接配置打开"); + } SmHotelCallbackTaskEntity task = taskMapper.selectById(id); if (task == null) { return R.failed("任务不存在"); } - // RUNNING 禁止;PENDING 允许补入 Redis(常见:队列关过 / Redis 丢过,页面一直「待处理」) - if (STATUS_RUNNING.equals(task.getStatus())) { - return R.failed("任务处理中,请稍候"); + String err = requeueOne(task, "人工重试入队"); + if (err != null) { + return R.failed(err); } + return R.ok(Boolean.TRUE, isHighPriority(task.getNotifyType()) ? "已重新入队(高优先级)" : "已重新入队"); + } + + @Override + public R retryFailed(String notifyType, String hotelCode, String hotelName, String createTimeBegin, + String createTimeEnd, Boolean includeSkipped, Integer limit) { if (!props().isCallbackQueueEnabled()) { return R.failed("回调队列未启用(callback-queue-enabled=false),请先在渠道对接配置打开"); } + int cap = limit == null ? 500 : Math.max(1, Math.min(limit, 2000)); + LocalDateTime begin = parseDateTime(createTimeBegin, true); + LocalDateTime end = parseDateTime(createTimeEnd, false); + var qw = Wrappers.lambdaQuery() + .in(Boolean.TRUE.equals(includeSkipped), SmHotelCallbackTaskEntity::getStatus, STATUS_FAILED, + STATUS_SKIPPED) + .eq(!Boolean.TRUE.equals(includeSkipped), SmHotelCallbackTaskEntity::getStatus, STATUS_FAILED) + .eq(StrUtil.isNotBlank(notifyType), SmHotelCallbackTaskEntity::getNotifyType, notifyType) + .like(StrUtil.isNotBlank(hotelCode), SmHotelCallbackTaskEntity::getHotelCode, hotelCode) + .like(StrUtil.isNotBlank(hotelName), SmHotelCallbackTaskEntity::getHotelName, hotelName) + .ge(begin != null, SmHotelCallbackTaskEntity::getCreateTime, begin) + .le(end != null, SmHotelCallbackTaskEntity::getCreateTime, end) + .orderByAsc(SmHotelCallbackTaskEntity::getCreateTime) + .last("limit " + cap); + List list = taskMapper.selectList(qw); + int n = 0; + for (SmHotelCallbackTaskEntity t : list) { + if (requeueOne(t, "批量重试失败任务入队") == null) { + n++; + } + } + Long remain = taskMapper.selectCount(Wrappers.lambdaQuery() + .in(Boolean.TRUE.equals(includeSkipped), SmHotelCallbackTaskEntity::getStatus, STATUS_FAILED, + STATUS_SKIPPED) + .eq(!Boolean.TRUE.equals(includeSkipped), SmHotelCallbackTaskEntity::getStatus, STATUS_FAILED) + .eq(StrUtil.isNotBlank(notifyType), SmHotelCallbackTaskEntity::getNotifyType, notifyType) + .like(StrUtil.isNotBlank(hotelCode), SmHotelCallbackTaskEntity::getHotelCode, hotelCode) + .like(StrUtil.isNotBlank(hotelName), SmHotelCallbackTaskEntity::getHotelName, hotelName) + .ge(begin != null, SmHotelCallbackTaskEntity::getCreateTime, begin) + .le(end != null, SmHotelCallbackTaskEntity::getCreateTime, end)); + String tip = "已重新入队 " + n + " 条"; + if (remain != null && remain > 0) { + tip += ",仍有约 " + remain + " 条失败可继续点「重试全部失败」"; + } + log.info("|回调任务|批量重试失败|enqueued={}|remain≈{}|cap={}|", n, remain, cap); + return R.ok(n, tip); + } + + @Override + public R retryBatch(Long[] ids) { + if (!props().isCallbackQueueEnabled()) { + return R.failed("回调队列未启用(callback-queue-enabled=false),请先在渠道对接配置打开"); + } + if (ids == null || ids.length == 0) { + return R.failed("请选择要重试的任务"); + } + int cap = Math.min(ids.length, 500); + int n = 0; + int skipped = 0; + for (int i = 0; i < cap; i++) { + Long id = ids[i]; + if (id == null) { + continue; + } + SmHotelCallbackTaskEntity task = taskMapper.selectById(id); + if (task == null) { + skipped++; + continue; + } + String err = requeueOne(task, "勾选批量重试入队"); + if (err == null) { + n++; + } + else { + skipped++; + } + } + return R.ok(n, "已重新入队 " + n + " 条" + (skipped > 0 ? ",跳过 " + skipped + " 条" : "")); + } + + /** + * 单条重新入队:RUNNING 拒绝;重置 attemptCount,避免已达上限的失败任务立刻再次 FAILED。 + * @return null=成功;非空=失败原因 + */ + private String requeueOne(SmHotelCallbackTaskEntity task, String resultMsg) { + if (task == null) { + return "任务不存在"; + } + if (STATUS_RUNNING.equals(task.getStatus())) { + return "任务处理中,请稍候"; + } if (task.getHotelId() == null && StrUtil.isNotBlank(task.getHotelCode())) { SmHotelEntity hotel = resolveWritableHotelByCode(task.getHotelCode(), SmHotelDataSource.SHANGYOU); if (hotel != null) { @@ -154,13 +244,15 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic } } task.setStatus(STATUS_PENDING); - task.setResultMsg("人工重试入队"); + task.setAttemptCount(0); + task.setResultMsg(resultMsg); task.setFinishTime(null); task.setCostMs(null); + task.setStartProcessTime(null); task.setUpdateTime(LocalDateTime.now()); taskMapper.updateById(task); enqueueTaskId(task.getId(), task.getNotifyType()); - return R.ok(Boolean.TRUE, isHighPriority(task.getNotifyType()) ? "已重新入队(高优先级)" : "已重新入队"); + return null; } @Override 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 8618688f..146a05ab 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 @@ -189,6 +189,14 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl listResourcePushSchedulePartners() { + return listEnabledDownstreamPartners().stream().filter(code -> { + HotelDownstreamProperties props = resolveDownstreamRuntime(code); + return props != null && props.isResourcePushJoinSchedule(); + }).toList(); + } + @Override public boolean isChannelEnabled(String channel) { return isChannelEnabled(channel, SmHotelChannelDirection.UPSTREAM); @@ -303,6 +311,27 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl 0) { target.setTimeoutMs(dto.getTimeoutMs()); } @@ -351,6 +380,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl> pendingByHotel = new ConcurrentHashMap<>(); + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "sm-hotel-downstream-auto-push"); + t.setDaemon(true); + return t; + }); + + @Override + public void scheduleAfterHotelChanged(Long hotelId) { + if (hotelId == null) { + return; + } + if (!isAutoPushEnabled()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + scheduleDebounced(hotelId); + } + }); + } + else { + scheduleDebounced(hotelId); + } + } + + @Override + public void closeProductsOnMatchedPartners(Long hotelId, String productCode, String roomTypeCode) { + if (hotelId == null || StrUtil.isBlank(productCode)) { + return; + } + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return; + } + List partners = resolvePartners(hotel); + if (CollUtil.isEmpty(partners)) { + return; + } + for (String partner : partners) { + try { + midService.closeProductDailyOnDownstream(hotelId, partner, productCode, roomTypeCode); + } + catch (Exception e) { + log.warn("|{}|删前关房失败|hotelId={}|partner={}|product={}|err={}|", TAG, hotelId, partner, + productCode, e.getMessage()); + } + } + } + + @Override + public void runScheduledScopePush() { + HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot(); + if (def == null) { + def = yamlDefaults; + } + if (def == null || !def.isResourcePushScheduleEnabled()) { + return; + } + Long tenantId = 1L; + try { + TenantBroker.runAs(tenantId, tid -> { + List partners = channelConfigService.listResourcePushSchedulePartners(); + if (CollUtil.isEmpty(partners)) { + log.debug("|{}|日更跳过|无 join-schedule 伙伴|", TAG); + return; + } + for (String partner : partners) { + try { + var started = syncService.startDownstreamScopePushAsync(partner, "SCHEDULE"); + log.info("|{}|日更已启动|partner={}|msg={}|", TAG, partner, + started == null ? null : started.getMsg()); + } + catch (Exception e) { + log.warn("|{}|日更启动失败|partner={}|err={}|", TAG, partner, e.getMessage()); + } + } + }); + } + catch (Exception e) { + log.error("|{}|日更异常", TAG, e); + } + } + + private void scheduleDebounced(Long hotelId) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = 1L; + } + int delaySec = resolveDebounceSeconds(); + Long tid = tenantId; + pendingByHotel.compute(hotelId, (id, old) -> { + if (old != null) { + old.cancel(false); + } + return scheduler.schedule(() -> { + pendingByHotel.remove(id); + try { + TenantBroker.runAs(tid, t -> pushHotelToMatchedPartners(id)); + } + catch (Exception e) { + log.warn("|{}|防抖执行失败|hotelId={}|err={}|", TAG, id, e.getMessage()); + } + }, delaySec, TimeUnit.SECONDS); + }); + log.debug("|{}|已安排防抖|hotelId={}|delaySec={}|", TAG, hotelId, delaySec); + } + + private void pushHotelToMatchedPartners(Long hotelId) { + SmHotelEntity hotel = hotelMapper.selectById(hotelId); + if (hotel == null) { + return; + } + List partners = resolvePartners(hotel); + if (CollUtil.isEmpty(partners)) { + log.debug("|{}|无命中下游范围|hotelId={}|", TAG, hotelId); + return; + } + for (String partner : partners) { + try { + R> r = midService.pushToDownstream(hotelId, partner); + if (r != null && r.getCode() == 0) { + log.info("|{}|完成|hotelId={}|partner={}|data={}|", TAG, hotelId, partner, r.getData()); + } + else { + log.warn("|{}|失败|hotelId={}|partner={}|msg={}|", TAG, hotelId, partner, + r == null ? "null" : r.getMsg()); + } + } + catch (Exception e) { + log.warn("|{}|异常|hotelId={}|partner={}|err={}|", TAG, hotelId, partner, e.getMessage()); + } + } + } + + private List resolvePartners(SmHotelEntity hotel) { + List enabled = channelConfigService.listEnabledDownstreamPartners(); + if (CollUtil.isEmpty(enabled)) { + return List.of(); + } + List matched = channelDeptService.listDownstreamPartnersForHotel(hotel.getId(), hotel.getDeptId(), + hotel.getDistrictAdcode(), hotel.getDistrict()); + if (CollUtil.isEmpty(matched)) { + if (enabled.contains("default")) { + return List.of("default"); + } + return List.of(); + } + return matched.stream().filter(enabled::contains).distinct().toList(); + } + + private boolean isAutoPushEnabled() { + HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot(); + if (def == null) { + def = yamlDefaults; + } + return def == null || def.isResourceAutoPushEnabled(); + } + + private int resolveDebounceSeconds() { + HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot(); + if (def == null) { + def = yamlDefaults; + } + int sec = def == null ? 90 : def.getResourceAutoPushDebounceSeconds(); + return Math.max(5, sec); + } + + @PreDestroy + public void shutdown() { + scheduler.shutdownNow(); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java new file mode 100644 index 00000000..c199bcfa --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelHuidingOrderServiceImpl.java @@ -0,0 +1,600 @@ +package com.bsd.pjcloud.supermarket.service.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantBroker; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; +import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; +import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient; +import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingDownstreamHotelAdapter; +import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper; +import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper; +import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; +import com.bsd.pjcloud.supermarket.service.SmHotelHuidingOrderService; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 慧订订单履约:直连校验/创建/取消 + 通知补单 + operationOrder 回写。 + */ +@Slf4j +@Service +public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderService { + + private static final String TAG = "慧订订单"; + + private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "sm-hotel-huiding-order"); + t.setDaemon(true); + return t; + }); + + private final SmHotelChannelConfigService channelConfigService; + + private final SmHotelOrderService hotelOrderService; + + private final SmHotelOrderMapper orderMapper; + + private final SmHotelMapper hotelMapper; + + private final SmHotelProductMapper productMapper; + + private final DownstreamHotelClient downstreamClient; + + private final SmHotelHuidingOrderService self; + + public SmHotelHuidingOrderServiceImpl(SmHotelChannelConfigService channelConfigService, + SmHotelOrderService hotelOrderService, SmHotelOrderMapper orderMapper, SmHotelMapper hotelMapper, + SmHotelProductMapper productMapper, DownstreamHotelClient downstreamClient, + @Lazy SmHotelHuidingOrderService self) { + this.channelConfigService = channelConfigService; + this.hotelOrderService = hotelOrderService; + this.orderMapper = orderMapper; + this.hotelMapper = hotelMapper; + this.productMapper = productMapper; + this.downstreamClient = downstreamClient; + this.self = self; + } + + @Override + public Map check(String partnerCode, JSONObject data) { + Map outer = ok200(); + Map checkBody = toCheckBody(data); + R> r = hotelOrderService.checkBookableForDownstream(checkBody); + Map inner = new LinkedHashMap<>(8); + List> priceList = new ArrayList<>(); + if (r == null || r.getCode() != 0 || r.getData() == null) { + inner.put("code", 2); + outer.put("data", inner); + outer.put("msg", r == null ? "校验失败" : StrUtil.blankToDefault(r.getMsg(), "校验失败")); + return outer; + } + Map d = r.getData(); + boolean bookable = Boolean.TRUE.equals(d.get("bookable")); + @SuppressWarnings("unchecked") + List> days = d.get("days") instanceof List list + ? (List>) (List) list + : List.of(); + String productId = StrUtil.trim(str(data.get("productId"))); + boolean full = false; + boolean shortage = false; + for (Map day : days) { + Map row = new LinkedHashMap<>(6); + row.put("dateStr", day.get("date")); + row.put("price", day.get("sellPrice")); + Object remain = day.get("remain"); + row.put("roomNum", remain == null ? "0" : String.valueOf(remain)); + row.put("supplierRateTypeCode", productId); + priceList.add(row); + int rem = 0; + try { + rem = remain == null ? 0 : Integer.parseInt(String.valueOf(remain)); + } + catch (Exception ignored) { + } + Object exists = day.get("exists"); + Object dailyStatus = day.get("dailyStatus"); + if (Boolean.FALSE.equals(exists) || (dailyStatus != null && !"1".equals(String.valueOf(dailyStatus)))) { + full = true; + } + int need = 1; + try { + need = Math.max(1, Integer.parseInt(String.valueOf(checkBody.get("roomCnt")))); + } + catch (Exception ignored) { + } + if (rem < need) { + shortage = true; + } + } + inner.put("orderPriceList", priceList); + if (bookable) { + inner.put("code", 1); + } + else if (Boolean.FALSE.equals(d.get("priceMatch"))) { + inner.put("code", 3); + } + else if (shortage && !full) { + inner.put("code", 4); + } + else { + inner.put("code", 2); + } + outer.put("data", inner); + outer.put("msg", bookable ? "可订" : String.valueOf(d.get("reasons"))); + return outer; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map create(String partnerCode, JSONObject data) { + Map outer = ok200(); + String channelOrderNo = StrUtil.trim(str(data.get("channelOrderNo"))); + if (StrUtil.isBlank(channelOrderNo)) { + return fail200("channelOrderNo 不能为空"); + } + SmHotelOrderEntity exist = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelOrderEntity::getChannelNo, channelOrderNo) + .last("limit 1")); + if (exist != null) { + outer.put("msg", "订单已存在(幂等)"); + outer.put("data", buildCreateData(exist)); + return outer; + } + MappedOrder mapped = mapCreateFields(partnerCode, data); + if (mapped.error != null) { + return fail200(mapped.error); + } + Map checkBody = new LinkedHashMap<>(); + checkBody.put("extHotelId", mapped.hotelCode); + checkBody.put("extProductId", mapped.productCode); + checkBody.put("extApartmentId", mapped.roomTypeCode); + checkBody.put("checkIn", mapped.checkIn.toString()); + checkBody.put("checkOut", mapped.checkOut.toString()); + checkBody.put("roomCnt", mapped.quantity); + checkBody.put("price", mapped.orderAmount); + R> check = hotelOrderService.checkBookableForDownstream(checkBody); + boolean bookable = check != null && check.getCode() == 0 && check.getData() != null + && Boolean.TRUE.equals(check.getData().get("bookable")); + if (!bookable) { + Object reasons = check == null || check.getData() == null ? "预检失败" : check.getData().get("reasons"); + return fail200("预检不可订:" + reasons); + } + + SmHotelOrderEntity order = new SmHotelOrderEntity(); + order.setPartnerCode(partnerCode); + order.setChannelNo(channelOrderNo); + order.setHotelId(mapped.hotel.getId()); + order.setHotelCode(mapped.hotelCode); + order.setGroupCode(mapped.hotel.getGroupCode()); + order.setRoomTypeCode(mapped.roomTypeCode); + order.setProductCode(mapped.productCode); + order.setPriceCode(mapped.product == null ? null : mapped.product.getPriceCode()); + order.setCheckInDate(mapped.checkIn); + order.setCheckOutDate(mapped.checkOut); + order.setCount(mapped.quantity); + order.setContactName(mapped.contactName); + order.setContactPhone(mapped.contactPhone); + order.setGuestJson(mapped.guestJson); + order.setSellAmount(mapped.orderAmount); + order.setMarkupPercent(BigDecimal.ZERO); + order.setOrderStatus("PENDING"); + order.setCompensateStatus("NONE"); + order.setInventoryStatus("NONE"); + order.setCompensateRound(0); + order.setReqJson(data.toString()); + order.setHuanqiPayload(data.toString()); + orderMapper.insert(order); + + Long tenantId = TenantContextHolder.getTenantId() == null ? 1L : TenantContextHolder.getTenantId(); + scheduleBookAndConfirm(tenantId, partnerCode, order.getId(), channelOrderNo, mapped.orderAmount); + + outer.put("msg", "下单成功,订单状态:待确认"); + outer.put("data", buildCreateData(order)); + return outer; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map cancel(String partnerCode, JSONObject data) { + String channelOrderNo = StrUtil.trim(str(data.get("channelOrderNo"))); + String supplierOrderNo = StrUtil.trim(str(data.get("orderNo"))); + if (StrUtil.isBlank(channelOrderNo)) { + return fail200("channelOrderNo 不能为空"); + } + SmHotelOrderEntity order = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelOrderEntity::getChannelNo, channelOrderNo) + .last("limit 1")); + if (order == null && StrUtil.isNotBlank(supplierOrderNo)) { + try { + order = orderMapper.selectById(Long.parseLong(supplierOrderNo)); + } + catch (Exception ignored) { + order = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelOrderEntity::getPlatformNo, supplierOrderNo) + .last("limit 1")); + } + } + if (order == null) { + return fail200("订单不存在"); + } + if (StrUtil.isNotBlank(partnerCode) && StrUtil.isNotBlank(order.getPartnerCode()) + && !partnerCode.equals(order.getPartnerCode())) { + return fail200("订单不属于当前伙伴"); + } + R r = hotelOrderService.cancelByAdmin(order.getId(), "慧订取消"); + if (r == null || r.getCode() != 0) { + return fail200(r == null ? "取消失败" : r.getMsg()); + } + try { + operationOrder(partnerCode, order.getChannelNo(), String.valueOf(order.getId()), 3, null); + } + catch (Exception e) { + log.warn("|{}|取消确认回写失败|channelNo={}|{}", TAG, channelOrderNo, e.getMessage()); + } + Map outer = ok200(); + outer.put("msg", "申请退单成功"); + outer.put("data", true); + return outer; + } + + @Override + public Map notify(String pass, String ordercode, String childcode) { + Map resp = new LinkedHashMap<>(4); + String child = StrUtil.trim(childcode); + String parent = StrUtil.trim(ordercode); + if (StrUtil.isBlank(child) && StrUtil.isBlank(parent)) { + resp.put("code", 1); + resp.put("message", "ordercode/childcode 不能为空"); + return resp; + } + String partner = resolvePartnerByNotifyPass(pass); + if (StrUtil.isBlank(partner)) { + resp.put("code", 1); + resp.put("message", "pass 无效"); + return resp; + } + String channelNo = StrUtil.blankToDefault(child, parent); + SmHotelOrderEntity exist = orderMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelOrderEntity::getChannelNo, channelNo) + .last("limit 1")); + if (exist != null) { + Long tenantId = TenantContextHolder.getTenantId() == null ? 1L : TenantContextHolder.getTenantId(); + if (StrUtil.isBlank(exist.getPlatformNo()) && !"CANCELLED".equals(exist.getOrderStatus())) { + scheduleBookAndConfirm(tenantId, partner, exist.getId(), exist.getChannelNo(), exist.getSellAmount()); + } + resp.put("code", 0); + resp.put("message", "本地已有订单,已触发履约"); + return resp; + } + // 本地没有:尝试查对方 + try { + R remote = queryRemote(partner, parent, child); + if (remote == null || remote.getCode() != 0 || remote.getData() == null) { + resp.put("code", 1); + resp.put("message", remote == null ? "查单失败" + : StrUtil.blankToDefault(remote.getMsg(), "查单失败(请确认 order-query-path)")); + return resp; + } + JSONObject detail = remote.getData(); + JSONObject createData = extractCreateDataFromQuery(detail, channelNo); + Map created = self.create(partner, createData); + Object code = created.get("code"); + if (code instanceof Number && ((Number) code).intValue() == 200) { + resp.put("code", 0); + resp.put("message", "查单落库成功"); + } + else { + resp.put("code", 1); + resp.put("message", StrUtil.blankToDefault(str(created.get("msg")), "落库失败")); + } + return resp; + } + catch (Exception e) { + log.warn("|{}|通知处理失败|child={}|{}", TAG, child, e.getMessage()); + resp.put("code", 1); + resp.put("message", StrUtil.blankToDefault(e.getMessage(), "通知处理失败")); + return resp; + } + } + + @Override + public R queryRemote(String partnerCode, String ordercode, String childcode) { + HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode); + if (props == null || !SmHotelDownstreamProtocol.isHuiding(props.getProtocol())) { + return R.failed("非慧订伙伴"); + } + Map body = new LinkedHashMap<>(); + body.put("key", HuidingDownstreamHotelAdapter.resolveKey(props)); + body.put("supplierId", props.getSupplierId()); + body.put("ordercode", ordercode); + body.put("childcode", childcode); + try { + JSONObject json = downstreamClient.orderQuery(partnerCode, body); + return R.ok(json); + } + catch (Exception e) { + return R.failed("查单失败:" + e.getMessage()); + } + } + + private void scheduleBookAndConfirm(Long tenantId, String partnerCode, Long orderId, String childOrderCode, + BigDecimal totalPrice) { + EXECUTOR.execute(() -> TenantBroker.runAs(tenantId, tid -> { + try { + R r = hotelOrderService.bookUpstreamForExisting(orderId); + SmHotelOrderEntity order = orderMapper.selectById(orderId); + if (r != null && r.getCode() == 0) { + operationOrder(partnerCode, childOrderCode, + order == null ? String.valueOf(orderId) + : StrUtil.blankToDefault(order.getPlatformNo(), String.valueOf(orderId)), + 1, totalPrice); + } + else { + log.warn("|{}|上游下单失败,回写拒绝|orderId={}|msg={}|", TAG, orderId, + r == null ? "null" : r.getMsg()); + operationOrder(partnerCode, childOrderCode, String.valueOf(orderId), 2, null); + if (order != null) { + order.setOrderStatus("NEED_MANUAL"); + order.setFailMsg(StrUtil.maxLength(r == null ? "上游下单失败" : r.getMsg(), 500)); + orderMapper.updateById(order); + } + } + } + catch (Exception e) { + log.warn("|{}|异步履约失败|orderId={}|{}", TAG, orderId, e.getMessage()); + try { + operationOrder(partnerCode, childOrderCode, String.valueOf(orderId), 2, null); + } + catch (Exception ignored) { + } + } + })); + } + + private void operationOrder(String partnerCode, String childOrderCode, String supplierOrderCode, int operationType, + BigDecimal totalPrice) { + HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode); + Map body = new LinkedHashMap<>(); + body.put("key", HuidingDownstreamHotelAdapter.resolveKey(props)); + body.put("supplierId", props.getSupplierId()); + body.put("childOrderCode", childOrderCode); + body.put("operationType", operationType); + if (StrUtil.isNotBlank(supplierOrderCode) && (operationType == 1 || operationType == 3)) { + body.put("supplierOrderCode", supplierOrderCode); + } + if (totalPrice != null && operationType == 1) { + body.put("totalPrice", totalPrice); + } + downstreamClient.huidingOperationOrder(partnerCode, body); + log.info("|{}|operationOrder|partner={}|child={}|type={}|", TAG, partnerCode, childOrderCode, operationType); + } + + private String resolvePartnerByNotifyPass(String pass) { + if (StrUtil.isBlank(pass)) { + return null; + } + for (String code : channelConfigService.listEnabledDownstreamPartners()) { + HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code); + if (p == null || !SmHotelDownstreamProtocol.isHuiding(p.getProtocol())) { + continue; + } + String expect = StrUtil.blankToDefault(StrUtil.trim(p.getOrderNotifyPass()), + HuidingDownstreamHotelAdapter.resolveKey(p)); + if (pass.equals(expect)) { + return code; + } + } + return null; + } + + private MappedOrder mapCreateFields(String partnerCode, JSONObject data) { + MappedOrder m = new MappedOrder(); + String hotelId = StrUtil.trim(str(data.get("hotelId"))); + String productId = StrUtil.trim(str(data.get("productId"))); + String roomTypeId = StrUtil.trim(str(data.get("roomTypeId"))); + m.checkIn = parseDay(str(data.get("checkinDate"))); + m.checkOut = parseDay(str(data.get("checkoutDate"))); + m.quantity = data.getInt("quantity") == null ? 1 : Math.max(1, data.getInt("quantity")); + m.orderAmount = toDecimal(data.get("orderAmount")); + if (StrUtil.isBlank(hotelId) || StrUtil.isBlank(productId) || m.checkIn == null || m.checkOut == null + || !m.checkIn.isBefore(m.checkOut)) { + m.error = "hotelId/productId/入住离店日期无效"; + return m; + } + m.hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelEntity::getHotelCode, hotelId) + .last("limit 1")); + if (m.hotel == null) { + m.error = "本地无此酒店:" + hotelId; + return m; + } + m.hotelCode = hotelId; + m.productCode = productId; + m.roomTypeCode = stripHuidingRoom(hotelId, roomTypeId); + m.product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, m.hotel.getId()) + .eq(SmHotelProductEntity::getProductCode, productId) + .last("limit 1")); + if (m.product == null) { + m.error = "本地无此产品:" + productId; + return m; + } + if (StrUtil.isBlank(m.roomTypeCode)) { + m.roomTypeCode = m.product.getRoomTypeCode(); + } + JSONObject contact = data.getJSONObject("contact"); + if (contact != null) { + m.contactName = StrUtil.trim(contact.getStr("name")); + m.contactPhone = StrUtil.trim(contact.getStr("phone")); + } + if (data.get("tourists") != null) { + m.guestJson = JSONUtil.toJsonStr(data.get("tourists")); + } + if (StrUtil.isBlank(m.contactName)) { + m.error = "联系人姓名不能为空"; + } + return m; + } + + private Map toCheckBody(JSONObject data) { + Map body = new LinkedHashMap<>(); + String hotelId = StrUtil.trim(str(data.get("hotelId"))); + String roomTypeId = StrUtil.trim(str(data.get("roomTypeId"))); + body.put("extHotelId", hotelId); + body.put("extProductId", StrUtil.trim(str(data.get("productId")))); + body.put("extApartmentId", stripHuidingRoom(hotelId, roomTypeId)); + body.put("checkIn", str(data.get("checkinDate"))); + body.put("checkOut", str(data.get("checkoutDate"))); + body.put("roomCnt", data.get("quantity")); + body.put("price", data.get("totalPrice")); + return body; + } + + private JSONObject extractCreateDataFromQuery(JSONObject detail, String fallbackChannelNo) { + JSONObject data = detail; + if (detail.containsKey("data") && detail.get("data") instanceof JSONObject) { + data = detail.getJSONObject("data"); + } + JSONObject out = new JSONObject(); + out.set("channelOrderNo", StrUtil.blankToDefault(data.getStr("childcode"), + StrUtil.blankToDefault(data.getStr("childOrderCode"), + StrUtil.blankToDefault(data.getStr("channelOrderNo"), fallbackChannelNo)))); + out.set("hotelId", firstStr(data, "hotelId", "supplierHotelId", "supplierhotelid")); + out.set("productId", firstStr(data, "productId", "rateHelp", "supplierRateTypeCode")); + out.set("roomTypeId", firstStr(data, "roomTypeId", "roomTypeCode", "supplierRoomCode")); + out.set("checkinDate", firstStr(data, "checkinDate", "checkIn", "beginDate")); + out.set("checkoutDate", firstStr(data, "checkoutDate", "checkOut", "endDate")); + out.set("quantity", data.get("quantity") != null ? data.get("quantity") : data.get("roomCnt")); + out.set("orderAmount", data.get("orderAmount") != null ? data.get("orderAmount") : data.get("totalPrice")); + if (data.get("contact") != null) { + out.set("contact", data.get("contact")); + } + else { + JSONObject c = new JSONObject(); + c.set("name", firstStr(data, "contactName", "guestName")); + c.set("phone", firstStr(data, "contactPhone", "guestPhone")); + out.set("contact", c); + } + if (data.get("tourists") != null) { + out.set("tourists", data.get("tourists")); + } + return out; + } + + private static Map buildCreateData(SmHotelOrderEntity order) { + Map data = new LinkedHashMap<>(6); + data.put("orderNo", order.getId() == null ? order.getChannelNo() : String.valueOf(order.getId())); + data.put("orderStatus", "1"); + data.put("orderStatusDesc", "待确认"); + data.put("orderAmount", order.getSellAmount()); + return data; + } + + private static Map ok200() { + Map m = new LinkedHashMap<>(4); + m.put("code", 200); + m.put("msg", "success"); + return m; + } + + private static Map fail200(String msg) { + Map m = new LinkedHashMap<>(4); + m.put("code", 500); + m.put("msg", StrUtil.blankToDefault(msg, "失败")); + return m; + } + + private static String stripHuidingRoom(String hotelCode, String roomTypeId) { + String room = StrUtil.trim(roomTypeId); + String hotel = StrUtil.trim(hotelCode); + if (StrUtil.isBlank(room) || StrUtil.isBlank(hotel)) { + return room; + } + String prefix = hotel + "_"; + if (room.startsWith(prefix)) { + return room.substring(prefix.length()); + } + return room; + } + + private static LocalDate parseDay(String s) { + if (StrUtil.isBlank(s)) { + return null; + } + try { + return LocalDate.parse(s.trim().substring(0, 10), DAY); + } + catch (Exception e) { + return null; + } + } + + private static BigDecimal toDecimal(Object o) { + if (o == null) { + return null; + } + try { + return new BigDecimal(String.valueOf(o)); + } + catch (Exception e) { + return null; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static String firstStr(JSONObject o, String... keys) { + if (o == null) { + return null; + } + for (String k : keys) { + String v = StrUtil.trim(o.getStr(k)); + if (StrUtil.isNotBlank(v)) { + return v; + } + } + return null; + } + + private static final class MappedOrder { + String error; + SmHotelEntity hotel; + SmHotelProductEntity product; + String hotelCode; + String productCode; + String roomTypeCode; + LocalDate checkIn; + LocalDate checkOut; + int quantity = 1; + BigDecimal orderAmount; + String contactName; + String contactPhone; + String guestJson; + } +} 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 352394b9..fecbe10a 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 @@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -11,6 +12,9 @@ import com.baomidou.mybatisplus.extension.toolkit.Db; import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties; import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties; import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol; +import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamPushKind; +import com.bsd.pjcloud.supermarket.integration.downstream.huiding.HuidingDownstreamHotelAdapter; import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.entity.*; import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient; @@ -20,6 +24,7 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient; 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.SmHotelDownstreamAutoPushService; import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService; import com.bsd.pjcloud.supermarket.service.SmHotelMidService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; @@ -29,6 +34,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +52,6 @@ import java.util.stream.Collectors; */ @Slf4j @Service -@RequiredArgsConstructor public class SmHotelMidServiceImpl implements SmHotelMidService { /** 多值 INSERT / JDBC 批更新分片大小 */ @@ -81,6 +86,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { private final SmHotelDownstreamPushLogService downstreamPushLogService; + private final SmHotelDownstreamAutoPushService downstreamAutoPushService; + private final SmHotelMapper hotelMapper; private final SmHotelRoomMapper roomMapper; @@ -91,6 +98,29 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { private final StringRedisTemplate stringRedisTemplate; + public SmHotelMidServiceImpl(ShangyouHotelClient upstreamClient, ShangyouHotelProperties upstreamProperties, + DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties, + SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService, + SmHotelChannelDeptService channelDeptService, SmHotelDownstreamPushLogService downstreamPushLogService, + @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService, SmHotelMapper hotelMapper, + SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper, + StringRedisTemplate stringRedisTemplate) { + this.upstreamClient = upstreamClient; + this.upstreamProperties = upstreamProperties; + this.downstreamClient = downstreamClient; + this.downstreamProperties = downstreamProperties; + this.priceConfigService = priceConfigService; + this.channelConfigService = channelConfigService; + this.channelDeptService = channelDeptService; + this.downstreamPushLogService = downstreamPushLogService; + this.downstreamAutoPushService = downstreamAutoPushService; + this.hotelMapper = hotelMapper; + this.roomMapper = roomMapper; + this.productMapper = productMapper; + this.dailyMapper = dailyMapper; + this.stringRedisTemplate = stringRedisTemplate; + } + @Override @Transactional(rollbackFor = Exception.class) public R> pullFromUpstream(Long hotelId) { @@ -159,7 +189,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { ensureRoomsBatch(hotel, roomTypesFromPrice); int productCount = persistProducts(hotel, productMap); - int dailyCount = persistDailies(hotel, dailyDrafts); + int dailyCount = persistDailies(hotel, dailyDrafts, start, endExclusive); if (roomCount == 0 && productCount == 0) { try { @@ -189,6 +219,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { result.put("upstreamTips", upstreamTips); log.info("|酒店拉取上游|结束|hotelCode={}|room={}|product={}|daily={}|tips={}|", hotelCode, roomCount, productCount, dailyCount, upstreamTips); + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(result); } @@ -307,13 +338,16 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } ensureRoomsBatch(hotel, roomTypesFromPrice); int productCount = persistProducts(hotel, productMap); - int dailyCount = persistDailies(hotel, dailyDrafts); + // 必须按本次拉取区间删再插:回调常含「今天之前」的日期,若只删 >=today 会撞 uk_product_date + int dailyCount = persistDailies(hotel, dailyDrafts, startInclusive, endExclusive); Map result = new HashMap<>(6); result.put("hotelCode", hotelCode); result.put("productCount", productCount); result.put("dailyCount", dailyCount); result.put("start", startInclusive.toString()); result.put("endExclusive", endExclusive.toString()); + // 回调区间拉价 / 手动滚价后,防抖推下游 + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(result); } @@ -362,14 +396,17 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { DownstreamPushLogContext.begin(null, Math.min(sampleMax, 4)); } try { - String skipReason = resolveDownstreamPushSkipReason(hotel, partner); - if (StrUtil.isNotBlank(skipReason)) { + String kind = resolveDownstreamPushKind(hotel, partner); + if (SmHotelDownstreamPushKind.isSkip(kind)) { + String skipReason = resolveDownstreamSkipReason(hotel, partner); recordDownstreamPushSkip(partner, hotel, skipReason); - Map one = new LinkedHashMap<>(6); + Map one = new LinkedHashMap<>(8); one.put("partnerCode", partner); + one.put("pushKind", SmHotelDownstreamPushKind.SKIP); one.put("skipped", true); one.put("skipReason", skipReason); Map result = new HashMap<>(8); + result.put("pushKind", SmHotelDownstreamPushKind.SKIP); result.put("skipped", true); result.put("skipReason", skipReason); result.put("partners", List.of(partner)); @@ -377,9 +414,20 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { result.put("partnerErrors", Collections.emptyList()); return R.ok(result); } - Map one = pushHotelToPartner(hotel, partner); + Map one; + if (SmHotelDownstreamPushKind.isClose(kind)) { + one = closeHotelDailyToPartner(hotel, partner, null); + one.put("pushKind", SmHotelDownstreamPushKind.CLOSE); + one.put("closed", true); + } + else { + one = pushHotelToPartner(hotel, partner); + one.put("pushKind", SmHotelDownstreamPushKind.SELL); + } one.put("partnerCode", partner); Map result = new HashMap<>(8); + result.put("pushKind", one.get("pushKind")); + result.put("closed", one.get("closed")); result.put("partners", List.of(partner)); result.put("partnerResults", List.of(one)); result.put("partnerErrors", Collections.emptyList()); @@ -396,32 +444,426 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } } - /** - * 下游可售过滤:下架 / 无产品 / 推送窗口内无日历 → 不调用下游接口(对方也卖不出去)。 - * @return 跳过原因;null 表示可继续推 - */ - private String resolveDownstreamPushSkipReason(SmHotelEntity hotel, String partnerCode) { - if (hotel == null) { - return "酒店不存在,跳过推送"; + @Override + public R> closeProductDailyOnDownstream(Long hotelId, String partnerCode, String productCode, + String roomTypeCode) { + if (StrUtil.isBlank(partnerCode) || StrUtil.isBlank(productCode)) { + return R.failed("伙伴与产品编码不能为空"); } - if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { - return "酒店已下架,下游无法售卖,跳过推送"; + SmHotelEntity hotel = requireHotel(hotelId, false); + String partner = partnerCode.trim(); + List allowed = resolvePushPartners(hotel); + if (CollUtil.isEmpty(allowed) || !allowed.contains(partner)) { + return R.failed("酒店未命中下游伙伴范围:" + partner); + } + boolean ownCtx = !DownstreamPushLogContext.hasContext(); + if (ownCtx) { + DownstreamPushLogContext.begin(null, 4); + } + try { + downstreamClient.assertPartnerReady(partner); + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partner); + if (SmHotelDownstreamProtocol.isHuiding(ds == null ? null : ds.getProtocol())) { + // 慧订按酒店窗口覆盖:删产品前先整窗关房,随后 schedule 会按剩余产品再推 + Map closed = closeHotelDailyToHuiding(hotel, partner); + closed.put("pushKind", SmHotelDownstreamPushKind.CLOSE); + closed.put("closed", true); + closed.put("productCode", productCode); + return R.ok(closed); + } + SmHotelProductEntity fake = new SmHotelProductEntity(); + fake.setProductCode(productCode.trim()); + fake.setRoomTypeCode(StrUtil.blankToDefault(roomTypeCode, productCode.trim())); + BigDecimal price = BigDecimal.ONE; + SmHotelProductEntity db = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotelId) + .eq(SmHotelProductEntity::getProductCode, productCode.trim()) + .last("LIMIT 1")); + if (db != null) { + fake.setRoomTypeCode(StrUtil.blankToDefault(db.getRoomTypeCode(), fake.getRoomTypeCode())); + if (db.getSellPrice() != null && db.getSellPrice().compareTo(BigDecimal.ZERO) > 0) { + price = db.getSellPrice(); + } + else if (db.getCostPrice() != null && db.getCostPrice().compareTo(BigDecimal.ZERO) > 0) { + price = db.getCostPrice(); + } + } + int n = pushCloseDailyRanges(partner, hotel, List.of(fake), price); + Map data = new LinkedHashMap<>(6); + data.put("pushKind", SmHotelDownstreamPushKind.CLOSE); + data.put("closed", true); + data.put("dailyCloseCount", n); + data.put("productCode", productCode); + return R.ok(data); + } + catch (Exception ex) { + log.warn("|下游删前关房失败|hotelId={}|partner={}|product={}|err={}", hotelId, partner, productCode, + ex.getMessage()); + return R.failed("关房推送失败:" + ex.getMessage()); + } + finally { + if (ownCtx) { + DownstreamPushLogContext.end(); + } + } + } + + /** + * 可售推 / 关房推 / 纯跳过(总览 §5.1)。 + * SKIP:无酒店或无产品;CLOSE:有产品但下架或窗口无日历;SELL:上架+有产品+窗口有日历。 + */ + private String resolveDownstreamPushKind(SmHotelEntity hotel, String partnerCode) { + if (hotel == null) { + return SmHotelDownstreamPushKind.SKIP; } Long hotelId = hotel.getId(); Long productCount = productMapper.selectCount(Wrappers.lambdaQuery() .eq(SmHotelProductEntity::getHotelId, hotelId)); if (productCount == null || productCount <= 0) { - return "无产品,下游无法售卖,跳过推送"; + return SmHotelDownstreamPushKind.SKIP; } + boolean offline = "OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus()); LocalDate[] window = resolveDownstreamDailyWindow(partnerCode); Long dailyCount = dailyMapper.selectCount(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotelId) .ge(SmHotelDailyEntity::getStayDate, window[0]) .le(SmHotelDailyEntity::getStayDate, window[1])); - if (dailyCount == null || dailyCount <= 0) { - return "推送窗口内无日历(" + window[0] + "~" + window[1] + "),下游无法售卖,跳过推送"; + boolean hasDaily = dailyCount != null && dailyCount > 0; + if (offline || !hasDaily) { + return SmHotelDownstreamPushKind.CLOSE; } - return null; + return SmHotelDownstreamPushKind.SELL; + } + + private String resolveDownstreamSkipReason(SmHotelEntity hotel, String partnerCode) { + if (hotel == null) { + return "酒店不存在,跳过推送"; + } + Long productCount = productMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotel.getId())); + if (productCount == null || productCount <= 0) { + return "无产品,无法关房也无法售卖,跳过推送(摘牌前请先关房)"; + } + return "跳过推送"; + } + + /** + * 关房推:环启推 dailyStatus=0;慧订推空价态窗口(对方打满房)。 + * @param onlyProducts 非空时只关这些产品(环启);慧订忽略,整窗清空 + */ + private Map closeHotelDailyToPartner(SmHotelEntity hotel, String partnerCode, + List onlyProducts) { + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + if (SmHotelDownstreamProtocol.isHuiding(ds == null ? null : ds.getProtocol())) { + return closeHotelDailyToHuiding(hotel, partnerCode); + } + Long hotelId = hotel.getId(); + downstreamClient.assertPartnerReady(partnerCode); + List products = onlyProducts; + if (CollUtil.isEmpty(products)) { + products = productMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotelId) + .isNotNull(SmHotelProductEntity::getProductCode)); + } + products = products.stream() + .filter(p -> StrUtil.isNotBlank(p.getProductCode()) && StrUtil.isNotBlank(p.getRoomTypeCode())) + .collect(Collectors.toList()); + if (CollUtil.isEmpty(products)) { + throw new IllegalArgumentException("无可关房产品 hotelId=" + hotelId); + } + int dailyClose = pushCloseDailyRanges(partnerCode, hotel, products, null); + log.info("|下游关房推送|hotelId={}|partner={}|products={}|ranges={}|", hotelId, partnerCode, products.size(), + dailyClose); + Map result = new HashMap<>(8); + result.put("productCount", products.size()); + result.put("dailyCloseCount", dailyClose); + return result; + } + + /** 慧订关房:hoteldatepush 空 roomPriceInfoList,窗口内打满房 */ + private Map closeHotelDailyToHuiding(SmHotelEntity hotel, String partnerCode) { + downstreamClient.assertPartnerReady(partnerCode); + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + LocalDate[] window = resolveDownstreamDailyWindow(partnerCode); + Map envelope = buildHuidingDatePushEnvelope(ds, hotel, window[0], window[1], + Collections.emptyList()); + downstreamClient.dailySync(partnerCode, hotel.getId(), + StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())), envelope); + log.info("|下游关房推送[慧订]|hotelId={}|partner={}|{}~{}|", hotel.getId(), partnerCode, window[0], window[1]); + Map result = new HashMap<>(6); + result.put("protocol", SmHotelDownstreamProtocol.HUIDING); + result.put("dailyCloseCount", 1); + result.put("roomPriceInfoSize", 0); + return result; + } + + /** + * 慧订可售推:pushStaticHotels + hoteldatepush(窗口全量)。 + * 房型编码全局唯一:{hotelCode}_{roomTypeCode}。 + */ + private Map pushHotelToHuiding(SmHotelEntity hotel, String partnerCode) { + Long hotelId = hotel.getId(); + String supplierHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotelId)); + downstreamClient.assertPartnerReady(partnerCode); + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + + List rooms = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelRoomEntity::getHotelId, hotelId)); + List products = productMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotelId)); + LocalDate[] window = resolveDownstreamDailyWindow(partnerCode); + LocalDate dailyFrom = window[0]; + LocalDate dailyTo = window[1]; + List dailies = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotelId) + .ge(SmHotelDailyEntity::getStayDate, dailyFrom) + .le(SmHotelDailyEntity::getStayDate, dailyTo) + .orderByAsc(SmHotelDailyEntity::getProductCode) + .orderByAsc(SmHotelDailyEntity::getStayDate)); + + Map staticEnvelope = buildHuidingStaticEnvelope(ds, hotel, rooms); + downstreamClient.hotelSync(partnerCode, hotelId, supplierHotelId, staticEnvelope); + + List> roomPriceInfoList = buildHuidingRoomPriceInfoList(supplierHotelId, products, dailies); + Map dateEnvelope = buildHuidingDatePushEnvelope(ds, hotel, dailyFrom, dailyTo, roomPriceInfoList); + downstreamClient.dailySync(partnerCode, hotelId, supplierHotelId, dateEnvelope); + + Map result = new HashMap<>(10); + result.put("protocol", SmHotelDownstreamProtocol.HUIDING); + result.put("roomCount", rooms.size()); + result.put("productCount", products.size()); + result.put("roomPriceInfoSize", roomPriceInfoList.size()); + result.put("dailyRowCount", dailies.size()); + result.put("windowFrom", dailyFrom.toString()); + result.put("windowTo", dailyTo.toString()); + return result; + } + + private Map buildHuidingStaticEnvelope(HotelDownstreamProperties ds, SmHotelEntity hotel, + List rooms) { + String supplierHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())); + Map hotelObj = new LinkedHashMap<>(); + hotelObj.put("hotelId", supplierHotelId); + hotelObj.put("hotelName", StrUtil.blankToDefault(hotel.getHotelName(), supplierHotelId)); + String cityName = extractHuanqiCityNameFromDistrict(StrUtil.trim(hotel.getDistrict())); + if (StrUtil.isBlank(cityName)) { + cityName = StrUtil.trim(hotel.getDistrict()); + } + hotelObj.put("cityName", StrUtil.blankToDefault(cityName, "未知")); + hotelObj.put("address", + StrUtil.blankToDefault(buildHuanqiFullAddress(hotel.getDistrict(), hotel.getAddress()), "-")); + hotelObj.put("phone", StrUtil.maxLength(StrUtil.blankToDefault(hotel.getPhone(), "00000000000"), 30)); + hotelObj.put("longtitude", hotel.getLongitude() == null ? "0" : hotel.getLongitude().toPlainString()); + hotelObj.put("latitude", hotel.getLatitude() == null ? "0" : hotel.getLatitude().toPlainString()); + hotelObj.put("star", mapHuidingStar(hotel.getStarLevel())); + List> roomTypeList = new ArrayList<>(); + for (SmHotelRoomEntity room : rooms) { + if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) { + continue; + } + Map r = new LinkedHashMap<>(); + r.put("roomTypeId", huidingRoomTypeCode(supplierHotelId, room.getRoomTypeCode())); + r.put("name", StrUtil.blankToDefault(room.getRoomTypeName(), room.getRoomTypeCode())); + if (room.getCapacity() != null) { + r.put("maxCapacity", String.valueOf(room.getCapacity())); + } + if (StrUtil.isNotBlank(room.getBedType())) { + r.put("bedType", room.getBedType()); + } + roomTypeList.add(r); + } + hotelObj.put("roomTypeList", roomTypeList); + // 文档 data 为 JSON 字符串 + Map envelope = new LinkedHashMap<>(); + envelope.put("key", HuidingDownstreamHotelAdapter.resolveKey(ds)); + envelope.put("supplierId", ds.getSupplierId()); + envelope.put("data", JSONUtil.toJsonStr(List.of(hotelObj))); + return envelope; + } + + private Map buildHuidingDatePushEnvelope(HotelDownstreamProperties ds, SmHotelEntity hotel, + LocalDate beginNight, LocalDate endNightInclusive, List> roomPriceInfoList) { + String supplierHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())); + // 文档 endDate 为退房日:最后一晚的次日 + LocalDate endCheckout = endNightInclusive.plusDays(1); + Map data = new LinkedHashMap<>(); + data.put("hotelId", supplierHotelId); + data.put("beginDate", beginNight.toString()); + data.put("endDate", endCheckout.toString()); + data.put("roomPriceInfoList", roomPriceInfoList == null ? Collections.emptyList() : roomPriceInfoList); + Map envelope = new LinkedHashMap<>(); + envelope.put("key", HuidingDownstreamHotelAdapter.resolveKey(ds)); + envelope.put("supplierId", ds.getSupplierId()); + envelope.put("type", "can"); + envelope.put("data", data); + return envelope; + } + + /** + * 按产品聚合日历;慧订 roomTypeCode 全局唯一;ratePlanCode 按早餐建议值。 + */ + private List> buildHuidingRoomPriceInfoList(String supplierHotelId, + List products, List dailies) { + Map productByCode = new LinkedHashMap<>(); + for (SmHotelProductEntity p : products) { + if (p != null && StrUtil.isNotBlank(p.getProductCode())) { + productByCode.put(p.getProductCode(), p); + } + } + Map> byProduct = new LinkedHashMap<>(); + for (SmHotelDailyEntity d : dailies) { + if (d == null || StrUtil.isBlank(d.getProductCode()) || !productByCode.containsKey(d.getProductCode())) { + continue; + } + byProduct.computeIfAbsent(d.getProductCode(), k -> new ArrayList<>()).add(d); + } + List> list = new ArrayList<>(); + for (Map.Entry> e : byProduct.entrySet()) { + SmHotelProductEntity p = productByCode.get(e.getKey()); + if (p == null || StrUtil.isBlank(p.getRoomTypeCode())) { + continue; + } + int breakfast = p.getMealCount() == null ? 0 : Math.max(0, p.getMealCount()); + List> planRateList = new ArrayList<>(); + for (SmHotelDailyEntity d : e.getValue()) { + if (d.getStayDate() == null) { + continue; + } + int stock = d.getStock() == null ? 0 : Math.max(0, d.getStock()); + int sold = d.getSold() == null ? 0 : Math.max(0, d.getSold()); + int remain = Math.max(0, stock - sold); + int dailyStatus = d.getDailyStatus() == null ? 1 : d.getDailyStatus(); + // 本地 1 开房 / 0 关房 → 慧订 0 可订 / 9 满房 + int status; + if (dailyStatus != 1 || remain <= 0) { + status = 9; + remain = 0; + } + else { + status = 0; + } + BigDecimal price = d.getSellPrice() != null ? d.getSellPrice() : d.getCostPrice(); + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + price = BigDecimal.ONE; + } + Map night = new LinkedHashMap<>(); + night.put("night", d.getStayDate().toString()); + night.put("price", price); + night.put("roomCount", remain); + night.put("status", status); + night.put("cancelType", 13); + night.put("daySelect", 0); + night.put("timeSelect", 18); + night.put("cutValue", 0); + planRateList.add(night); + } + if (planRateList.isEmpty()) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("breakfast", Math.min(breakfast, 2)); + info.put("planRateList", planRateList); + info.put("ratePlanCode", huidingRatePlanCode(breakfast)); + info.put("ratePlanName", huidingRatePlanName(breakfast)); + info.put("rateHelp", p.getProductCode()); + info.put("roomTypeCode", huidingRoomTypeCode(supplierHotelId, p.getRoomTypeCode())); + info.put("roomTypeName", StrUtil.blankToDefault(p.getProductName(), p.getRoomTypeCode())); + list.add(info); + } + return list; + } + + private static String huidingRoomTypeCode(String hotelCode, String roomTypeCode) { + String room = StrUtil.trim(roomTypeCode); + String hotel = StrUtil.trim(hotelCode); + if (StrUtil.isBlank(room)) { + return hotel; + } + if (StrUtil.isNotBlank(hotel) && !room.startsWith(hotel + "_")) { + return hotel + "_" + room; + } + return room; + } + + /** 慧订建议:5 不含早、1 单早、2 双早、7 三早 */ + private static String huidingRatePlanCode(int breakfast) { + if (breakfast <= 0) { + return "5"; + } + if (breakfast == 1) { + return "1"; + } + if (breakfast == 2) { + return "2"; + } + return "7"; + } + + private static String huidingRatePlanName(int breakfast) { + if (breakfast <= 0) { + return "不含早"; + } + if (breakfast == 1) { + return "单早"; + } + if (breakfast == 2) { + return "双早"; + } + return "多早"; + } + + /** 本地星级(常见 1~5 或对方枚举值)映射慧订星级枚举 */ + private static int mapHuidingStar(Integer starLevel) { + if (starLevel == null) { + return 20; + } + int n = starLevel; + if (n >= 50) { + return n; + } + if (n >= 5) { + return 55; + } + if (n == 4) { + return 45; + } + if (n == 3) { + return 35; + } + return 20; + } + + /** 按产品推整窗关房区间;返回推送的 list 段数 */ + private int pushCloseDailyRanges(String partnerCode, SmHotelEntity hotel, List products, + BigDecimal defaultPrice) { + String extHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())); + LocalDate[] window = resolveDownstreamDailyWindow(partnerCode); + LocalDate from = window[0]; + LocalDate to = window[1]; + int pushed = 0; + for (SmHotelProductEntity p : products) { + BigDecimal price = defaultPrice; + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice(); + } + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + price = BigDecimal.ONE; + } + Map item = new LinkedHashMap<>(); + item.put("startDate", from.toString()); + item.put("endDate", to.toString()); + item.put("dailyPrice", price); + item.put("dailyNumber", 0); + item.put("dailyStatus", 0); + Map body = new LinkedHashMap<>(); + body.put("extHotelId", extHotelId); + body.put("extApartmentId", p.getRoomTypeCode()); + body.put("extProductId", p.getProductCode()); + body.put("list", List.of(item)); + downstreamClient.dailySync(partnerCode, hotel.getId(), extHotelId, body); + pushed++; + } + return pushed; } /** 与实际 dailySync 一致的可推日历窗口:[from, to] 含首尾 */ @@ -490,8 +932,12 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return matched.stream().filter(enabledSet::contains).distinct().toList(); } - /** 按下游协议约定顺序推送:酒店→房型→产品→日历 */ + /** 按下游协议约定顺序推送:环启四段;慧订静态+价态 */ private Map pushHotelToPartner(SmHotelEntity hotel, String partnerCode) { + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + if (SmHotelDownstreamProtocol.isHuiding(ds == null ? null : ds.getProtocol())) { + return pushHotelToHuiding(hotel, partnerCode); + } Long hotelId = hotel.getId(); String extHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId())); downstreamClient.assertPartnerReady(partnerCode); @@ -1029,6 +1475,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { room.setRoomTypeCode(room.getRoomTypeCode().trim()); roomMapper.updateById(room); } + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(roomMapper.selectById(room.getId())); } @@ -1046,7 +1493,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (productCnt != null && productCnt > 0) { return R.failed("请先删除该房型下的产品"); } - return R.ok(roomMapper.deleteById(roomId) > 0); + boolean ok = roomMapper.deleteById(roomId) > 0; + if (ok) { + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + } + return R.ok(ok); } @Override @@ -1108,21 +1559,27 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } productMapper.updateById(product); } + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(productMapper.selectById(product.getId())); } @Override - @Transactional(rollbackFor = Exception.class) public R removeLocalProduct(Long hotelId, Long productId) { requireLocalHotel(hotelId); SmHotelProductEntity old = productMapper.selectById(productId); if (old == null || !Objects.equals(old.getHotelId(), hotelId)) { return R.failed("产品不存在"); } + // 删前先关房(HTTP,不放事务里);避免删光后无法清对方旧价 + downstreamAutoPushService.closeProductsOnMatchedPartners(hotelId, old.getProductCode(), old.getRoomTypeCode()); dailyMapper.delete(Wrappers.lambdaQuery() .eq(SmHotelDailyEntity::getHotelId, hotelId) .eq(SmHotelDailyEntity::getProductId, productId)); - return R.ok(productMapper.deleteById(productId) > 0); + boolean ok = productMapper.deleteById(productId) > 0; + if (ok) { + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + } + return R.ok(ok); } @Override @@ -1198,6 +1655,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } SmHotelDailyEntity saved = dailyMapper.selectById(daily.getId()); fillDailyRemain(saved); + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(saved); } @@ -1209,7 +1667,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (old == null || !Objects.equals(old.getHotelId(), hotelId)) { return R.failed("日历行不存在"); } - return R.ok(dailyMapper.deleteById(dailyId) > 0); + boolean ok = dailyMapper.deleteById(dailyId) > 0; + if (ok) { + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); + } + return R.ok(ok); } @Override @@ -1332,6 +1794,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { String msg = fillMode ? String.format("已补齐 %d 天行,跳过已有 %d", toInsert.size(), skippedExist) : String.format("已调整 %d 行,新补齐 %d,配额不足跳过 %d", toUpdate.size(), toInsert.size(), skippedStock); + downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); return R.ok(data, msg); } @@ -1667,8 +2130,48 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return toInsert.size() + toUpdate.size(); } - private int persistDailies(SmHotelEntity hotel, List drafts) { - dailyMapper.physicalDeleteFromDate(hotel.getId(), LocalDate.now()); + /** + * 日历落库:先按半开区间物理删除再批量插入。 + *

+ * 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。 + * 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。 + */ + private int persistDailies(SmHotelEntity hotel, List drafts, LocalDate fromInclusive, + LocalDate toExclusive) { + LocalDate delFrom = fromInclusive; + LocalDate delTo = toExclusive; + if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) { + // 兜底:无有效区间时按草稿最值;再无则只清今天起 + if (CollUtil.isNotEmpty(drafts)) { + LocalDate min = null; + LocalDate max = null; + for (DailyDraft d : drafts) { + if (d == null || d.stayDate == null) { + continue; + } + if (min == null || d.stayDate.isBefore(min)) { + min = d.stayDate; + } + if (max == null || d.stayDate.isAfter(max)) { + max = d.stayDate; + } + } + if (min != null && max != null) { + delFrom = min; + delTo = max.plusDays(1); + } + } + if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) { + delFrom = LocalDate.now(); + delTo = null; + } + } + if (delTo == null) { + dailyMapper.physicalDeleteFromDate(hotel.getId(), delFrom); + } + else { + dailyMapper.physicalDeleteRange(hotel.getId(), delFrom, delTo); + } if (CollUtil.isEmpty(drafts)) { return 0; } 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 d19d3f17..1b9a935b 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 @@ -20,6 +20,7 @@ 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.SmHotelDownstreamAutoPushService; import com.bsd.pjcloud.supermarket.service.SmHotelService; import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO; import com.bsd.pjcloud.common.core.constant.CommonConstants; @@ -28,8 +29,8 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -48,7 +49,6 @@ import java.util.concurrent.ConcurrentHashMap; @Slf4j @Service -@RequiredArgsConstructor public class SmHotelServiceImpl extends ServiceImpl implements SmHotelService { @@ -63,6 +63,16 @@ public class SmHotelServiceImpl extends ServiceImpl pageScope(Page page, LambdaQueryWrapper wrapper) { @@ -186,7 +196,12 @@ public class SmHotelServiceImpl extends ServiceImpl 0; + boolean ok = baseMapper.updateById(entity) > 0; + if (ok) { + // 上架状态/资料变更后防抖推下游(下架走关房推) + downstreamAutoPushService.scheduleAfterHotelChanged(entity.getId()); + } + return ok; } @Override diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java index 769da6ef..21437685 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java @@ -770,7 +770,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { done.setStatus(STATUS_SUCCESS); done.setStage("DONE"); done.setStageMsg("下游「" + partnerCode + "」全量推送完成:成功 " + ok + " / 失败 " + fail + " / 跳过 " + skip - + "(下架/无产品/无日历);明细见下游推送日志(同步日志ID=" + logId + ")"); + + "(成功含可售推与关房推;跳过=无产品等);明细见下游推送日志(同步日志ID=" + logId + ")"); done.setProgressPct(100); done.setSuccessCount(ok); done.setFailCount(fail); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/util/HotelAreaAdcodeUtil.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/util/HotelAreaAdcodeUtil.java index 01312538..11aa44e1 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/util/HotelAreaAdcodeUtil.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/util/HotelAreaAdcodeUtil.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.supermarket.util; import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; /** * 行政区划 adcode 前缀匹配(与 App 列表筛选一致): @@ -11,6 +13,41 @@ public final class HotelAreaAdcodeUtil { private HotelAreaAdcodeUtil() { } + /** + * 酒店列表按位置区域过滤: + * - 省/市 adcode 用前缀覆盖全部下级; + * - 同时兼容仅填了区域名称、未落 district_adcode 的酒店(按名称模糊)。 + */ + public static void applyHotelListFilter(LambdaQueryWrapper wrapper, Long districtAdcode, + String areaName) { + if (districtAdcode == null && StrUtil.isBlank(areaName)) { + return; + } + if (districtAdcode == null) { + String name = StrUtil.trim(areaName); + String shortName = shortenAreaName(name); + wrapper.and(w -> { + w.like(SmHotelEntity::getDistrict, name).or().like(SmHotelEntity::getAddress, name); + if (StrUtil.isNotBlank(shortName) && !shortName.equals(name)) { + w.or().like(SmHotelEntity::getDistrict, shortName).or().like(SmHotelEntity::getAddress, shortName); + } + }); + return; + } + String prefix = areaAdcodePrefix(districtAdcode); + String name = StrUtil.trim(areaName); + String shortName = shortenAreaName(name); + wrapper.and(w -> { + w.apply("district_adcode is not null and CAST(district_adcode AS CHAR) LIKE {0}", prefix + "%"); + if (StrUtil.isNotBlank(name)) { + w.or().like(SmHotelEntity::getDistrict, name).or().like(SmHotelEntity::getAddress, name); + } + if (StrUtil.isNotBlank(shortName) && !shortName.equals(name)) { + w.or().like(SmHotelEntity::getDistrict, shortName).or().like(SmHotelEntity::getAddress, shortName); + } + }); + } + /** 330000→33,330100→3301,330102→330102 */ public static String areaAdcodePrefix(long adcode) { String s = String.valueOf(adcode); 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 2d770d2a..479cfe89 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/resources/application.yml @@ -139,6 +139,11 @@ spring: # order-pull-page-size: 50 # order-pull-cron: 0 */5 * * * ? # 进程时钟(改完需重启) # order-pull-schedule-enabled: true # false=整机关定时,手动仍可拉 +# # --- 资源自动推 / 日更(可售推·关房推·跳过,见环启下游对接总览 §5.1)--- +# resource-auto-push-enabled: true # 本地/回调变更后防抖自动推 +# resource-auto-push-debounce-seconds: 90 +# resource-push-schedule-enabled: true # 日更总开关 +# resource-push-cron: 0 30 2 * * ? # 日更时钟;伙伴 YAML 另开 resource-push-join-schedule # # --- 推送流水(伙伴 YAML 可覆盖 mode/截断/保留天)--- # push-log-mode: fail_only # fail_only|sample|all # push-log-sample-success-max: 20