This commit is contained in:
zjf committed 2026-10-28 16:55:22 +08:00
1 parent b05616400b
commit 0283fe84c3
10 files changed
+653 -21

No files matched your search

@@ -33,7 +33,7 @@ public class SmHotelDownstreamPushLogEntity extends Model<SmHotelDownstreamPushL
@Schema(description = "外部酒店ID")
private String extHotelId;
@Schema(description = "动作:hotel_sync/apartment_sync/product_sync/daily_sync/push_skip/push_partial")
@Schema(description = "动作:hotel_sync/apartment_sync/product_sync/daily_sync/push_skip/push_partial/auto_push")
private String action;
@Schema(description = "完整请求URL")
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamInboundRetryService;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import io.swagger.v3.oas.annotations.Operation;
@@ -29,6 +30,8 @@ public class SmHotelDownstreamPushLogController {
private final SmHotelMidService midService;
private final SmHotelDownstreamInboundRetryService inboundRetryService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_view')")
@@ -45,7 +48,7 @@ public class SmHotelDownstreamPushLogController {
}
@SysLog("下游推送流水重试")
@Operation(summary = "按流水关联酒店重新推送到下游(整店:酒店/房型/产品/日历)")
@Operation(summary = "重试:资源流水整店重推;入站流水按原请求体重放业务(不验签)")
@PostMapping("/retry/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_retry')")
public R<Map<String, Object>> 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());
}
@@ -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);
}
}
@@ -14,6 +14,8 @@ import java.util.concurrent.TimeUnit;
/**
* 圣诺侧酒店/房型/计划 ID 与本地映射(推送 getInfo/plan 后写入,订单入站反查)。
* 不改表结构,落 Redis;TTL 默认 30 天。
* <p>
* 同时写伙伴维度反查 + 按 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;
}
@@ -19,4 +19,9 @@ public interface SmHotelDownstreamAutoPushService {
* 日更:对 resource-push-join-schedule=true 的启用伙伴,启动范围全量推(可售/关房/跳过由 push 内部分类)。
*/
void runScheduledScopePush();
/**
* 保存日历/产品等后的前端提示文案;自动推关闭时说明需手动推。
*/
String autoPushScheduleHint();
}
@@ -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<Map<String, Object>> retryInbound(SmHotelDownstreamPushLogEntity row);
}
@@ -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<Long, ScheduledFuture<?>> 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<String> 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<Map<String, Object>> 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<Map<String, Object>> 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<String> resolvePartners(SmHotelEntity hotel) {
List<String> enabled = channelConfigService.listEnabledDownstreamPartners();
if (CollUtil.isEmpty(enabled)) {
@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> replayHuanqi(String partner, String action, String rawBody) {
if (StrUtil.isBlank(partner)) {
throw new IllegalArgumentException("缺少下游伙伴,无法重放环启入站");
}
JSONObject payload = JSONUtil.parseObj(rawBody);
Map<String, Object> resp = new LinkedHashMap<>(4);
if ("inbound_check".equals(action)) {
R<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> failResp(String msg) {
Map<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
@@ -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);
@@ -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.<SmHotelProductEntity>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.<SmHotelProductEntity>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<SmHotelEntity> 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<String, Long> 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<SmHotelEntity> buildRecoverCandidates(String hotelName) {
List<SmHotelEntity> ordered = new ArrayList<>();
if (StrUtil.isNotBlank(hotelName)) {
List<SmHotelEntity> byName = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.like(SmHotelEntity::getHotelName, hotelName.length() > 24 ? hotelName.substring(0, 24) : hotelName)
.last("LIMIT 20"));
if (CollUtil.isNotEmpty(byName)) {
ordered.addAll(byName);
}
}
List<SmHotelEntity> withEid = hotelMapper.selectList(Wrappers.<SmHotelEntity>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<String, Long> matchRoomsFromGetInfo(Long hotelId, JSONArray nsRooms) {
Map<String, Long> out = new LinkedHashMap<>();
if (hotelId == null || nsRooms == null || nsRooms.isEmpty()) {
return out;
}
List<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId));
Map<String, Long> byElong = new HashMap<>();
Map<String, Long> 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<String, Long> 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.<SmHotelRoomEntity>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.<SmHotelProductEntity>lambdaQuery().eq(SmHotelProductEntity::getHotelId, hotelId);
if (StrUtil.isNotBlank(roomType)) {
qw.eq(SmHotelProductEntity::getRoomTypeCode, roomType);
}
List<SmHotelProductEntity> 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<String, Long> rooms = idMappingCache.rooms(map);
Map<String, Long> 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;