diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamPushLogEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamPushLogEntity.java index 4624e786..fa108d2c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamPushLogEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelDownstreamPushLogEntity.java @@ -33,7 +33,7 @@ public class SmHotelDownstreamPushLogEntity extends Model> retry(@PathVariable Long id) { @@ -53,15 +56,19 @@ public class SmHotelDownstreamPushLogController { if (row == null) { return R.failed("流水不存在"); } + String action = StrUtil.blankToDefault(row.getAction(), ""); + if (action.startsWith("inbound_")) { + return inboundRetryService.retryInbound(row); + } if (row.getHotelId() == null) { return R.failed("流水缺少酒店ID,无法重推"); } if (StrUtil.isBlank(row.getPartnerCode())) { return R.failed("流水缺少下游伙伴,无法重推"); } - String action = StrUtil.blankToDefault(row.getAction(), ""); - if ("order_query".equalsIgnoreCase(action) || "order_detail".equalsIgnoreCase(action)) { - return R.failed("订单查询类流水不支持从此处重推,请使用订单对接能力"); + if ("order_query".equalsIgnoreCase(action) || "order_detail".equalsIgnoreCase(action) + || "operation_order".equalsIgnoreCase(action) || "auto_push".equalsIgnoreCase(action)) { + return R.failed("该类流水不支持从此处重推"); } return midService.pushToDownstream(row.getHotelId(), row.getPartnerCode().trim()); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAccessLog.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAccessLog.java index 5df6c6ae..2bb3923d 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAccessLog.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/DownstreamInboundAccessLog.java @@ -62,7 +62,10 @@ public class DownstreamInboundAccessLog { if (o == null) { return null; } - String direct = StrUtil.blankToDefault(o.getStr("hotelId"), o.getStr("extHotelId")); + // 圣诺 hotel_id;慧订/环启 hotelId、extHotelId + String direct = StrUtil.blankToDefault(o.getStr("hotelId"), + StrUtil.blankToDefault(o.getStr("extHotelId"), + StrUtil.blankToDefault(str(o.get("hotel_id")), o.getStr("hotelCode")))); if (StrUtil.isNotBlank(direct)) { return direct; } @@ -133,4 +136,8 @@ public class DownstreamInboundAccessLog { return null; } } + + 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/shengnuo/ShengnuoIdMappingCache.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java index ef22fb7f..235d9604 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/downstream/shengnuo/ShengnuoIdMappingCache.java @@ -14,6 +14,8 @@ import java.util.concurrent.TimeUnit; /** * 圣诺侧酒店/房型/计划 ID 与本地映射(推送 getInfo/plan 后写入,订单入站反查)。 * 不改表结构,落 Redis;TTL 默认 30 天。 + *

+ * 同时写伙伴维度反查 + 按 nsHotelId 全局反查,避免入站伙伴码与推送时不一致时找不到。 */ @Component @RequiredArgsConstructor @@ -23,6 +25,9 @@ public class ShengnuoIdMappingCache { private static final String REV = "sm:hotel:shengnuo:rev:"; + /** nsHotelId → partner|localHotelId,跨伙伴兜底 */ + private static final String REV_NS = "sm:hotel:shengnuo:ns:"; + private static final long TTL_DAYS = 30L; private final StringRedisTemplate redisTemplate; @@ -49,6 +54,7 @@ public class ShengnuoIdMappingCache { JSONObject o = new JSONObject(); o.set("localHotelId", localHotelId); o.set("nsHotelId", nsHotelId); + o.set("partner", partner); o.set("rooms", rooms); o.set("plans", plans); o.set("revRooms", revRooms); @@ -56,6 +62,32 @@ public class ShengnuoIdMappingCache { redisTemplate.opsForValue().set(KEY + partner + ":" + localHotelId, o.toString(), TTL_DAYS, TimeUnit.DAYS); redisTemplate.opsForValue().set(REV + partner + ":" + nsHotelId, String.valueOf(localHotelId), TTL_DAYS, TimeUnit.DAYS); + redisTemplate.opsForValue().set(REV_NS + nsHotelId, partner + "|" + localHotelId, TTL_DAYS, TimeUnit.DAYS); + } + + /** + * 按圣诺酒店 id 反查本地酒店。优先当前伙伴 key,再回落全局 ns 索引。 + * @return [0]=用于读 map 的 partner,[1]=localHotelId;找不到返回 null + */ + public String[] resolveLocalByNs(String partner, long nsHotelId) { + if (nsHotelId <= 0) { + return null; + } + if (StrUtil.isNotBlank(partner)) { + Long local = localHotelIdByNsStrict(partner, nsHotelId); + if (local != null) { + return new String[] { partner, String.valueOf(local) }; + } + } + String raw = redisTemplate.opsForValue().get(REV_NS + nsHotelId); + if (StrUtil.isBlank(raw)) { + return null; + } + String[] parts = raw.split("\\|", 2); + if (parts.length != 2 || StrUtil.hasBlank(parts[0], parts[1])) { + return null; + } + return parts; } public JSONObject getByLocal(String partner, Long localHotelId) { @@ -75,6 +107,19 @@ public class ShengnuoIdMappingCache { } public Long localHotelIdByNs(String partner, long nsHotelId) { + String[] hit = resolveLocalByNs(partner, nsHotelId); + if (hit == null) { + return null; + } + try { + return Long.parseLong(hit[1].trim()); + } + catch (Exception e) { + return null; + } + } + + private Long localHotelIdByNsStrict(String partner, long nsHotelId) { if (StrUtil.isBlank(partner) || nsHotelId <= 0) { return null; } 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 index 6431df7b..bde7296f 100644 --- 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 @@ -19,4 +19,9 @@ public interface SmHotelDownstreamAutoPushService { * 日更:对 resource-push-join-schedule=true 的启用伙伴,启动范围全量推(可售/关房/跳过由 push 内部分类)。 */ void runScheduledScopePush(); + + /** + * 保存日历/产品等后的前端提示文案;自动推关闭时说明需手动推。 + */ + String autoPushScheduleHint(); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamInboundRetryService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamInboundRetryService.java new file mode 100644 index 00000000..b42172c6 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelDownstreamInboundRetryService.java @@ -0,0 +1,14 @@ +package com.bsd.pjcloud.supermarket.service; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity; + +import java.util.Map; + +/** + * 下游入站流水重放:用流水里的请求体重跑业务(跳过对方签名/nonce),便于联调自助重试。 + */ +public interface SmHotelDownstreamInboundRetryService { + + R> retryInbound(SmHotelDownstreamPushLogEntity row); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAutoPushServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAutoPushServiceImpl.java index 8c8bd370..73870773 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAutoPushServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamAutoPushServiceImpl.java @@ -2,15 +2,18 @@ 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.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.entity.SmHotelDownstreamPushLogEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper; 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.SmHotelSyncService; import jakarta.annotation.PreDestroy; @@ -49,15 +52,19 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu private final SmHotelSyncService syncService; + private final SmHotelDownstreamPushLogService downstreamPushLogService; + public SmHotelDownstreamAutoPushServiceImpl(HotelDownstreamProperties yamlDefaults, SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService, - SmHotelMapper hotelMapper, @Lazy SmHotelMidService midService, @Lazy SmHotelSyncService syncService) { + SmHotelMapper hotelMapper, @Lazy SmHotelMidService midService, @Lazy SmHotelSyncService syncService, + SmHotelDownstreamPushLogService downstreamPushLogService) { this.yamlDefaults = yamlDefaults; this.channelConfigService = channelConfigService; this.channelDeptService = channelDeptService; this.hotelMapper = hotelMapper; this.midService = midService; this.syncService = syncService; + this.downstreamPushLogService = downstreamPushLogService; } private final ConcurrentHashMap> pendingByHotel = new ConcurrentHashMap<>(); @@ -147,6 +154,14 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu } } + @Override + public String autoPushScheduleHint() { + if (!isAutoPushEnabled()) { + return "自动推下游已关闭,请到酒店列表手动「推下游」"; + } + return "约" + resolveDebounceSeconds() + "秒后自动推已授权下游(圣诺/环启等),无需再点手动推"; + } + private void scheduleDebounced(Long hotelId) { Long tenantId = TenantContextHolder.getTenantId(); if (tenantId == null) { @@ -178,12 +193,16 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu } List partners = resolvePartners(hotel); if (CollUtil.isEmpty(partners)) { - log.debug("|{}|无命中下游范围|hotelId={}|", TAG, hotelId); + // 提到 info:改价后「没推出去」最常见原因是渠道范围未命中 + log.info("|{}|无命中下游范围,跳过自动推|hotelId={}|deptId={}|", TAG, hotelId, hotel.getDeptId()); return; } for (String partner : partners) { + long t0 = System.currentTimeMillis(); try { R> r = midService.pushToDownstream(hotelId, partner); + long cost = System.currentTimeMillis() - t0; + recordAutoPushSummary(hotel, partner, r, null, cost); if (r != null && r.getCode() == 0) { log.info("|{}|完成|hotelId={}|partner={}|data={}|", TAG, hotelId, partner, r.getData()); } @@ -193,11 +212,43 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu } } catch (Exception e) { + long cost = System.currentTimeMillis() - t0; + recordAutoPushSummary(hotel, partner, null, e.getMessage(), cost); log.warn("|{}|异常|hotelId={}|partner={}|err={}|", TAG, hotelId, partner, e.getMessage()); } } } + /** + * 变更后自动推摘要流水(不受 fail_only 限制),方便在「下游推送日志」核对改价是否推出去。 + */ + private void recordAutoPushSummary(SmHotelEntity hotel, String partner, R> r, String exMsg, + long costMs) { + try { + boolean ok = r != null && r.getCode() == 0; + SmHotelDownstreamPushLogEntity row = new SmHotelDownstreamPushLogEntity(); + row.setPartnerCode(partner); + row.setHotelId(hotel == null ? null : hotel.getId()); + row.setExtHotelId(hotel == null ? null + : StrUtil.blankToDefault(hotel.getSHotelId(), + StrUtil.blankToDefault(hotel.getHotelCode(), + hotel.getId() == null ? null : String.valueOf(hotel.getId())))); + row.setAction("auto_push"); + row.setSuccess(ok ? "1" : "0"); + String msg = ok ? "变更后自动推送完成" + : StrUtil.blankToDefault(exMsg, r == null ? "自动推送失败" : StrUtil.blankToDefault(r.getMsg(), "自动推送失败")); + row.setErrorMsg(StrUtil.maxLength(msg, 900)); + if (r != null && r.getData() != null) { + row.setResponseBody(StrUtil.maxLength(JSONUtil.toJsonStr(r.getData()), 4000)); + } + row.setCostMs(costMs); + downstreamPushLogService.record(row); + } + catch (Exception e) { + log.warn("|{}|摘要流水落库失败|partner={}|err={}|", TAG, partner, e.getMessage()); + } + } + private List resolvePartners(SmHotelEntity hotel) { List enabled = channelConfigService.listEnabledDownstreamPartners(); if (CollUtil.isEmpty(enabled)) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java new file mode 100644 index 00000000..b4ba5fd5 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelDownstreamInboundRetryServiceImpl.java @@ -0,0 +1,259 @@ +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.bsd.pjcloud.common.core.util.R; +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.DownstreamInboundAccessLog; +import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter; +import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; +import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamInboundRetryService; +import com.bsd.pjcloud.supermarket.service.SmHotelHuanqiOrderService; +import com.bsd.pjcloud.supermarket.service.SmHotelHuidingOrderService; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; +import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstreamInboundRetryService { + + private static final String TAG = "入站重放"; + + private final SmHotelChannelConfigService channelConfigService; + + private final SmHotelShengnuoOrderService shengnuoOrderService; + + private final SmHotelHuidingOrderService huidingOrderService; + + private final SmHotelHuanqiOrderService huanqiOrderService; + + private final SmHotelOrderService hotelOrderService; + + private final DownstreamInboundAccessLog inboundAccessLog; + + @Override + public R> retryInbound(SmHotelDownstreamPushLogEntity row) { + if (row == null) { + return R.failed("流水不存在"); + } + String action = StrUtil.blankToDefault(row.getAction(), "").trim(); + if (!action.startsWith("inbound_")) { + return R.failed("非入站流水,请用资源重推"); + } + if (StrUtil.isBlank(row.getRequestBody())) { + return R.failed("流水无请求体,无法重放"); + } + String partner = StrUtil.trim(row.getPartnerCode()); + String url = StrUtil.blankToDefault(row.getRequestUrl(), ""); + String protocol = resolveProtocol(partner, url, action); + long t0 = System.currentTimeMillis(); + Map resp; + try { + if (SmHotelDownstreamProtocol.isShengnuo(protocol) || urlContains(url, "shengnuo")) { + resp = replayShengnuo(partner, action, row.getRequestBody()); + if (StrUtil.isBlank(partner)) { + partner = peekShengnuoPartner(row.getRequestBody()); + } + } + else if (SmHotelDownstreamProtocol.isHuiding(protocol) || urlContains(url, "huiding")) { + resp = replayHuiding(partner, action, row.getRequestBody()); + } + else if (SmHotelDownstreamProtocol.isHuanqi(protocol) || urlContains(url, "/third/party/api/order")) { + resp = replayHuanqi(partner, action, row.getRequestBody()); + } + else { + return R.failed("无法识别入站协议(伙伴=" + partner + ",url=" + url + "),请确认渠道协议配置"); + } + } + catch (Exception e) { + log.warn("|{}|失败|action={}|partner={}|{}|", TAG, action, partner, e.getMessage()); + resp = failResp(e.getMessage()); + } + inboundAccessLog.record(partner, action, StrUtil.blankToDefault(url, "retry:" + action), + row.getRequestBody(), resp, System.currentTimeMillis() - t0); + Integer code = extractCode(resp); + boolean ok = code != null && (code == 0 || code == 200); + String msg = extractMsg(resp); + Map data = new LinkedHashMap<>(4); + data.put("inboundRetry", true); + data.put("action", action); + data.put("partnerCode", partner); + data.put("result", resp); + if (ok) { + return R.ok(data, StrUtil.blankToDefault(msg, "入站重放成功")); + } + return R.failed(data, StrUtil.blankToDefault(msg, "入站重放失败")); + } + + private Map replayShengnuo(String partner, String action, String rawBody) { + JSONObject body = JSONUtil.parseObj(rawBody); + String p = StrUtil.blankToDefault(partner, peekShengnuoPartner(rawBody)); + if (StrUtil.isBlank(p)) { + throw new IllegalArgumentException("缺少下游伙伴,无法重放圣诺入站"); + } + return switch (action) { + case "inbound_create" -> shengnuoOrderService.createOrder(p, body); + case "inbound_cancel" -> shengnuoOrderService.orderCancel(p, body); + case "inbound_timeout" -> shengnuoOrderService.orderConfirmTimeOut(p, body); + default -> throw new IllegalArgumentException("圣诺不支持重放动作:" + action); + }; + } + + private Map replayHuiding(String partner, String action, String rawBody) { + JSONObject envelope = JSONUtil.parseObj(rawBody); + if ("inbound_notify".equals(action)) { + String pass = StrUtil.trim(envelope.getStr("pass")); + String ordercode = StrUtil.trim(envelope.getStr("ordercode")); + String childcode = StrUtil.trim(envelope.getStr("childcode")); + return huidingOrderService.notify(pass, ordercode, childcode); + } + if (StrUtil.isBlank(partner)) { + throw new IllegalArgumentException("缺少下游伙伴,无法重放慧订入站"); + } + JSONObject data = parseHuidingData(envelope); + return switch (action) { + case "inbound_check" -> huidingOrderService.check(partner, data); + case "inbound_create" -> huidingOrderService.create(partner, data); + case "inbound_cancel" -> huidingOrderService.cancel(partner, data); + default -> throw new IllegalArgumentException("慧订不支持重放动作:" + action); + }; + } + + private Map replayHuanqi(String partner, String action, String rawBody) { + if (StrUtil.isBlank(partner)) { + throw new IllegalArgumentException("缺少下游伙伴,无法重放环启入站"); + } + JSONObject payload = JSONUtil.parseObj(rawBody); + Map resp = new LinkedHashMap<>(4); + if ("inbound_check".equals(action)) { + R> r = hotelOrderService.checkBookableForDownstream(payload); + if (r != null && r.getCode() == 0) { + resp.put("code", 200); + resp.put("msg", "success"); + resp.put("data", r.getData()); + } + else { + resp.put("code", 500); + resp.put("msg", r == null ? "校验失败" : r.getMsg()); + } + return resp; + } + if ("inbound_push".equals(action)) { + R> r = huanqiOrderService.applyPayload(partner, payload); + if (r != null && r.getCode() == 0) { + resp.put("code", 200); + resp.put("msg", "success"); + resp.put("data", r.getData()); + } + else { + resp.put("code", 500); + resp.put("msg", r == null ? "处理失败" : r.getMsg()); + } + return resp; + } + throw new IllegalArgumentException("环启不支持重放动作:" + action); + } + + private String resolveProtocol(String partner, String url, String action) { + if (StrUtil.isNotBlank(partner) && !"unknown".equalsIgnoreCase(partner)) { + HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(partner); + if (p != null && StrUtil.isNotBlank(p.getProtocol())) { + return SmHotelDownstreamProtocol.normalize(p.getProtocol()); + } + } + if (urlContains(url, "shengnuo") || "inbound_timeout".equals(action)) { + return SmHotelDownstreamProtocol.SHENGNUO; + } + if (urlContains(url, "huiding") || "inbound_notify".equals(action)) { + return SmHotelDownstreamProtocol.HUIDING; + } + if (urlContains(url, "/third/party/api/order")) { + return SmHotelDownstreamProtocol.HUANQI; + } + return null; + } + + private String peekShengnuoPartner(String rawBody) { + try { + JSONObject body = JSONUtil.parseObj(rawBody); + String appId = StrUtil.blankToDefault(body.getStr("appid"), body.getStr("appId")); + if (StrUtil.isBlank(appId)) { + return null; + } + for (String code : channelConfigService.listEnabledDownstreamPartners()) { + HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code); + if (p == null || !SmHotelDownstreamProtocol.isShengnuo(p.getProtocol())) { + continue; + } + if (appId.equals(ShengnuoDownstreamHotelAdapter.resolveAppId(p))) { + return code; + } + } + } + catch (Exception ignored) { + } + return null; + } + + private static JSONObject parseHuidingData(JSONObject envelope) { + Object data = envelope.get("data"); + if (data instanceof JSONObject jo) { + return jo; + } + String s = envelope.getStr("data"); + if (StrUtil.isBlank(s)) { + s = data == null ? null : String.valueOf(data); + } + if (StrUtil.isBlank(s)) { + throw new IllegalArgumentException("慧订报文 data 为空"); + } + return JSONUtil.parseObj(s); + } + + private static boolean urlContains(String url, String part) { + return StrUtil.isNotBlank(url) && url.toLowerCase().contains(part.toLowerCase()); + } + + private static Map failResp(String msg) { + Map m = new LinkedHashMap<>(6); + m.put("code", 1); + m.put("success", false); + m.put("msg", StrUtil.blankToDefault(msg, "重放失败")); + m.put("status", "error"); + m.put("data", Map.of()); + return m; + } + + private static Integer extractCode(Map resp) { + if (resp == null || resp.get("code") == null) { + return null; + } + try { + return Integer.parseInt(String.valueOf(resp.get("code"))); + } + catch (Exception e) { + return null; + } + } + + private static String extractMsg(Map resp) { + if (resp == null) { + return null; + } + Object msg = resp.get("msg"); + if (msg == null) { + msg = resp.get("message"); + } + return msg == null ? null : String.valueOf(msg); + } +} 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 6fa2d95d..f1837438 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 @@ -2210,7 +2210,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { fillDailyRemain(saved); downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); - return R.ok(saved); + return R.ok(saved, "已保存," + downstreamAutoPushService.autoPushScheduleHint()); } @Override @@ -2226,7 +2226,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); } - return R.ok(ok); + return R.ok(ok, ok ? "已删除," + downstreamAutoPushService.autoPushScheduleHint() : null); } @Override @@ -2347,8 +2347,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { data.put("skippedExist", skippedExist); data.put("skippedStockLtSold", skippedStock); String msg = fillMode - ? String.format("已补齐 %d 天行,跳过已有 %d", toInsert.size(), skippedExist) - : String.format("已调整 %d 行,新补齐 %d,配额不足跳过 %d", toUpdate.size(), toInsert.size(), skippedStock); + ? String.format("已补齐 %d 天行,跳过已有 %d;%s", toInsert.size(), skippedExist, + downstreamAutoPushService.autoPushScheduleHint()) + : String.format("已调整 %d 行,新补齐 %d,配额不足跳过 %d;%s", toUpdate.size(), toInsert.size(), skippedStock, + downstreamAutoPushService.autoPushScheduleHint()); downstreamAutoPushService.scheduleAfterHotelChanged(hotelId); availabilityNotifyService.scheduleCheckAfterHotelChanged(hotelId); return R.ok(data, msg); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java index d636f081..55be4af5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelShengnuoOrderServiceImpl.java @@ -1,6 +1,8 @@ package com.bsd.pjcloud.supermarket.service.impl; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; @@ -11,12 +13,14 @@ import com.bsd.pjcloud.supermarket.constant.SmHotelStayDates; 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.entity.SmHotelRoomEntity; import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient; import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoDownstreamHotelAdapter; import com.bsd.pjcloud.supermarket.integration.downstream.shengnuo.ShengnuoIdMappingCache; 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.mapper.SmHotelRoomMapper; import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; import com.bsd.pjcloud.supermarket.service.SmHotelShengnuoOrderService; @@ -28,8 +32,13 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 圣诺订单:入站落库并订上游;成功后 orderChange=已接单(2);取消成功后 orderChange=已取消(5)。 @@ -49,6 +58,8 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ private static final DateTimeFormatter DAY = DateTimeFormatter.ISO_LOCAL_DATE; + private static final Pattern BREAKFAST_IN_PLAN = Pattern.compile("含\\s*(\\d+)\\s*份"); + private final SmHotelChannelConfigService channelConfigService; private final SmHotelOrderService hotelOrderService; @@ -59,6 +70,8 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ private final SmHotelProductMapper productMapper; + private final SmHotelRoomMapper roomMapper; + private final DownstreamHotelClient downstreamClient; private final ShengnuoIdMappingCache idMappingCache; @@ -67,13 +80,14 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ public SmHotelShengnuoOrderServiceImpl(SmHotelChannelConfigService channelConfigService, SmHotelOrderService hotelOrderService, SmHotelOrderMapper orderMapper, SmHotelMapper hotelMapper, - SmHotelProductMapper productMapper, DownstreamHotelClient downstreamClient, + SmHotelProductMapper productMapper, SmHotelRoomMapper roomMapper, DownstreamHotelClient downstreamClient, ShengnuoIdMappingCache idMappingCache, ShengnuoDownstreamHotelAdapter shengnuoAdapter) { this.channelConfigService = channelConfigService; this.hotelOrderService = hotelOrderService; this.orderMapper = orderMapper; this.hotelMapper = hotelMapper; this.productMapper = productMapper; + this.roomMapper = roomMapper; this.downstreamClient = downstreamClient; this.idMappingCache = idMappingCache; this.shengnuoAdapter = shengnuoAdapter; @@ -224,19 +238,46 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ long nsHotelId = toLong(body.get("hotel_id")); long nsRoomId = toLong(body.get("room_type_id")); long nsPlanId = toLong(body.get("price_plan_id")); - Long localHotelId = idMappingCache.localHotelIdByNs(partnerCode, nsHotelId); - SmHotelEntity hotel = null; - if (localHotelId != null) { - hotel = hotelMapper.selectById(localHotelId); + if (nsHotelId <= 0) { + m.error = "hotel_id 不能为空"; + return m; } - cn.hutool.json.JSONObject map = localHotelId == null ? null - : idMappingCache.getByLocal(partnerCode, localHotelId); + String mapPartner = partnerCode; + Long localHotelId = null; + String[] hit = idMappingCache.resolveLocalByNs(partnerCode, nsHotelId); + if (hit != null) { + mapPartner = hit[0]; + try { + localHotelId = Long.parseLong(hit[1].trim()); + } + catch (Exception ignored) { + } + } + SmHotelEntity hotel = localHotelId == null ? null : hotelMapper.selectById(localHotelId); + JSONObject map = localHotelId == null ? null : idMappingCache.getByLocal(mapPartner, localHotelId); + // Redis 映射缺失/过期:按 getInfo(eid) 回查本地酒店并重建映射 if (hotel == null) { - m.error = "未找到本地酒店(请先推送资源建立映射)nsHotelId=" + nsHotelId; + ResolvedHotel recovered = recoverHotelMapping(partnerCode, nsHotelId, body); + if (recovered != null) { + hotel = recovered.hotel; + map = recovered.map; + mapPartner = recovered.mapPartner; + log.info("|{}|入站映射已重建|nsHotelId={}|localHotelId={}|partner={}|", TAG, nsHotelId, hotel.getId(), + mapPartner); + } + } + if (hotel == null) { + m.error = "未找到本地酒店(请先推送资源建立映射,或核对外部酒店ID/sHotelId)nsHotelId=" + nsHotelId; return m; } String roomType = idMappingCache.roomTypeByNs(map, nsRoomId); + if (StrUtil.isBlank(roomType)) { + roomType = matchLocalRoomType(hotel.getId(), nsRoomId, StrUtil.trim(str(body.get("room_type"))), map); + } String productCode = idMappingCache.productByNsPlan(map, nsPlanId); + if (StrUtil.isBlank(productCode)) { + productCode = matchLocalProduct(hotel.getId(), roomType, body, nsPlanId, map, mapPartner, nsHotelId); + } if (StrUtil.isBlank(productCode) && StrUtil.isNotBlank(roomType)) { SmHotelProductEntity one = productMapper.selectOne(Wrappers.lambdaQuery() .eq(SmHotelProductEntity::getHotelId, hotel.getId()) @@ -247,7 +288,8 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ } } if (StrUtil.isBlank(productCode)) { - m.error = "未匹配本地产品 planId=" + nsPlanId + " roomId=" + nsRoomId; + m.error = "未匹配本地产品 planId=" + nsPlanId + " roomId=" + nsRoomId + + "(请重新推该酒店资源以刷新房型/计划映射)"; return m; } SmHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() @@ -286,6 +328,206 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ return m; } + /** + * Redis 无映射时:用报文酒店名优先,再对有外部酒店ID的本地店调 getInfo,比对圣诺 hotel id 后重建映射。 + */ + private ResolvedHotel recoverHotelMapping(String partnerCode, long nsHotelId, JSONObject body) { + HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode); + if (ds == null || !SmHotelDownstreamProtocol.isShengnuo(ds.getProtocol())) { + return null; + } + List candidates = buildRecoverCandidates(StrUtil.trim(str(body.get("hotel_name")))); + for (SmHotelEntity h : candidates) { + if (h == null || h.getId() == null) { + continue; + } + String eid = StrUtil.blankToDefault(StrUtil.trim(h.getSHotelId()), StrUtil.trim(h.getHotelCode())); + if (StrUtil.isBlank(eid)) { + continue; + } + try { + JSONObject infoResp = shengnuoAdapter.getInfo(partnerCode, ds, eid); + JSONObject infoData = infoResp == null ? null : infoResp.getJSONObject("data"); + long id = infoData == null ? 0L : infoData.getLong("id", 0L); + if (id != nsHotelId) { + continue; + } + Map rooms = matchRoomsFromGetInfo(h.getId(), infoData.getJSONArray("rooms")); + idMappingCache.save(partnerCode, h.getId(), nsHotelId, rooms, Map.of()); + JSONObject map = idMappingCache.getByLocal(partnerCode, h.getId()); + ResolvedHotel out = new ResolvedHotel(); + out.hotel = h; + out.map = map; + out.mapPartner = partnerCode; + return out; + } + catch (Exception ex) { + log.debug("|{}|getInfo 回查失败|hotelId={}|eid={}|{}|", TAG, h.getId(), eid, ex.getMessage()); + } + } + return null; + } + + private List buildRecoverCandidates(String hotelName) { + List ordered = new ArrayList<>(); + if (StrUtil.isNotBlank(hotelName)) { + List byName = hotelMapper.selectList(Wrappers.lambdaQuery() + .like(SmHotelEntity::getHotelName, hotelName.length() > 24 ? hotelName.substring(0, 24) : hotelName) + .last("LIMIT 20")); + if (CollUtil.isNotEmpty(byName)) { + ordered.addAll(byName); + } + } + List withEid = hotelMapper.selectList(Wrappers.lambdaQuery() + .isNotNull(SmHotelEntity::getSHotelId) + .ne(SmHotelEntity::getSHotelId, "") + .orderByDesc(SmHotelEntity::getUpdateTime) + .last("LIMIT 60")); + if (CollUtil.isNotEmpty(withEid)) { + for (SmHotelEntity h : withEid) { + if (h == null || h.getId() == null) { + continue; + } + boolean exists = ordered.stream().anyMatch(x -> x != null && h.getId().equals(x.getId())); + if (!exists) { + ordered.add(h); + } + } + } + return ordered; + } + + private Map matchRoomsFromGetInfo(Long hotelId, JSONArray nsRooms) { + Map out = new LinkedHashMap<>(); + if (hotelId == null || nsRooms == null || nsRooms.isEmpty()) { + return out; + } + List rooms = roomMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelRoomEntity::getHotelId, hotelId)); + Map byElong = new HashMap<>(); + Map byName = new HashMap<>(); + for (int i = 0; i < nsRooms.size(); i++) { + JSONObject r = nsRooms.getJSONObject(i); + if (r == null) { + continue; + } + long id = r.getLong("id", 0L); + if (id <= 0) { + continue; + } + String elong = StrUtil.trim(r.getStr("elong_room_type_code")); + String name = StrUtil.trim(r.getStr("name")); + if (StrUtil.isNotBlank(elong) && !"0000".equals(elong)) { + byElong.putIfAbsent(elong, id); + } + if (StrUtil.isNotBlank(name)) { + byName.putIfAbsent(name, id); + } + } + for (SmHotelRoomEntity room : rooms) { + if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) { + continue; + } + Long nsId = byElong.get(room.getRoomTypeCode()); + if (nsId == null && StrUtil.isNotBlank(room.getRoomTypeName())) { + nsId = byName.get(StrUtil.trim(room.getRoomTypeName())); + } + if (nsId != null) { + out.put(room.getRoomTypeCode(), nsId); + } + } + return out; + } + + /** 映射无房型时:用 getInfo 反查结果或报文房型名兜底 */ + private String matchLocalRoomType(Long hotelId, long nsRoomId, String roomTypeName, JSONObject map) { + if (map != null && nsRoomId > 0) { + for (Map.Entry e : idMappingCache.rooms(map).entrySet()) { + if (e.getValue() != null && e.getValue() == nsRoomId) { + return e.getKey(); + } + } + } + if (StrUtil.isBlank(roomTypeName) || hotelId == null) { + return null; + } + SmHotelRoomEntity room = roomMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelRoomEntity::getHotelId, hotelId) + .eq(SmHotelRoomEntity::getRoomTypeName, roomTypeName) + .last("LIMIT 1")); + return room == null ? null : room.getRoomTypeCode(); + } + + /** + * 计划映射缺失时:按房型 + 早餐份数(报文 price_plan 如「含2份早餐」)匹配本地产品,并回写 plan 映射。 + */ + private String matchLocalProduct(Long hotelId, String roomType, JSONObject body, long nsPlanId, JSONObject map, + String mapPartner, long nsHotelId) { + if (hotelId == null) { + return null; + } + var qw = Wrappers.lambdaQuery().eq(SmHotelProductEntity::getHotelId, hotelId); + if (StrUtil.isNotBlank(roomType)) { + qw.eq(SmHotelProductEntity::getRoomTypeCode, roomType); + } + List list = productMapper.selectList(qw); + if (CollUtil.isEmpty(list)) { + return null; + } + Integer breakfast = parseBreakfast(str(body.get("price_plan"))); + SmHotelProductEntity hit = null; + if (breakfast != null) { + for (SmHotelProductEntity p : list) { + int meal = p.getMealCount() == null ? 0 : Math.max(0, p.getMealCount()); + if (meal == breakfast) { + hit = p; + break; + } + } + } + if (hit == null && list.size() == 1) { + hit = list.get(0); + } + if (hit == null) { + return null; + } + if (nsPlanId > 0 && StrUtil.isNotBlank(mapPartner)) { + Map rooms = idMappingCache.rooms(map); + Map plans = new HashMap<>(idMappingCache.plans(map)); + plans.put(hit.getProductCode(), nsPlanId); + idMappingCache.save(mapPartner, hotelId, nsHotelId, rooms, plans); + } + return hit.getProductCode(); + } + + private static Integer parseBreakfast(String pricePlan) { + if (StrUtil.isBlank(pricePlan)) { + return null; + } + String s = pricePlan.trim(); + if (s.contains("无早") || s.contains("不含早")) { + return 0; + } + Matcher m = BREAKFAST_IN_PLAN.matcher(s); + if (m.find()) { + try { + return Integer.parseInt(m.group(1)); + } + catch (Exception ignored) { + } + } + if (s.contains("含早")) { + return 1; + } + return null; + } + + private static final class ResolvedHotel { + SmHotelEntity hotel; + JSONObject map; + String mapPartner; + } + private void pushOrderChange(String partnerCode, String orderId, int status) { if (StrUtil.isBlank(partnerCode) || StrUtil.isBlank(orderId)) { return;