修复回调异常问题,增加慧订业务

This commit is contained in:
zjf committed 2026-10-16 17:26:27 +08:00
1 parent aa5a699a52
commit 069f2f4b09
28 files changed
+2368 -40

No files matched your search

@@ -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<String> supported() {
Set<String> 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;
}
@@ -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);
}
}
@@ -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;
}
@@ -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 * * ?";
}
@@ -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<Integer> 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<Integer> retryBatch(@RequestBody Long[] ids) {
return callbackTaskService.retryBatch(ids);
}
@SysLog("酒店回调待处理任务批量重新入队")
@Operation(summary = "把滞留 PENDING(及超时 RUNNING)重新推入 Redis 队列")
@PostMapping("/requeuePending")
@@ -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");
@@ -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)。
* <pre>
* POST /third/party/huiding/order/check
* POST /third/party/huiding/order/create
* POST /third/party/huiding/order/cancel
* POST /third/party/huiding/order/notify
* </pre>
*/
@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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> err500(String msg) {
Map<String, Object> m = new LinkedHashMap<>(4);
m.put("code", 500);
m.put("msg", StrUtil.blankToDefault(msg, "失败"));
return m;
}
}
@@ -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);
@@ -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<String, Object> 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<String, Object> 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);
}
}
@@ -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() {
}
}
@@ -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;
}
}
@@ -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<String, Object> 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);
}
}
}
@@ -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);
}
}
}
@@ -23,6 +23,13 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper<SmHotelDailyEntity
@Delete("DELETE FROM canteen_hotel_daily WHERE hotel_id = #{hotelId} AND stay_date >= #{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<SmHotelDailyEntity> list);
/**
@@ -26,6 +26,18 @@ public interface SmHotelCallbackTaskService {
/** 失败/跳过任务重新入队 */
R<Boolean> retry(Long id);
/**
* 按当前筛选条件批量重试失败任务(默认只重试 FAILED;可含 SKIPPED)。
* @return 成功入队条数
*/
R<Integer> retryFailed(String notifyType, String hotelCode, String hotelName, String createTimeBegin,
String createTimeEnd, Boolean includeSkipped, Integer limit);
/**
* 按任务 ID 列表批量重试(跳过 RUNNING;单次上限 500)。
*/
R<Integer> retryBatch(Long[] ids);
/** 删除单条回调任务 */
R<Boolean> removeTask(Long id);
@@ -38,6 +38,9 @@ public interface SmHotelChannelConfigService extends IService<SmHotelChannelConf
/** 已启用的下游伙伴编码列表 */
List<String> listEnabledDownstreamPartners();
/** 参与资源日更定时推送的启用伙伴(resource-push-join-schedule=true) */
List<String> listResourcePushSchedulePartners();
/** 渠道是否在库表中启用(无行时回落 YAML enabled;默认按上游) */
boolean isChannelEnabled(String channel);
@@ -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();
}
@@ -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<String, Object> check(String partnerCode, JSONObject data);
/** 创建订单 */
Map<String, Object> create(String partnerCode, JSONObject data);
/** 取消订单 */
Map<String, Object> cancel(String partnerCode, JSONObject data);
/**
* 订单通知:pass 鉴权后按 childcode 查本地或拉对方查单再履约。
* 外层返回慧订通知形态 code 0/1。
*/
Map<String, Object> notify(String pass, String ordercode, String childcode);
/** 主动查对方订单(联调/排障) */
R<JSONObject> queryRemote(String partnerCode, String ordercode, String childcode);
}
@@ -42,10 +42,17 @@ public interface SmHotelMidService {
/**
* 将本地酒店+房型+产品+日历推送给下游销售方。
* 内部分类:可售推 / 关房推 / 纯跳过(见 SmHotelDownstreamPushKind)。
* @param partnerCode 指定下游伙伴编码;必填,仅推该伙伴(且须在范围映射内)
*/
R<Map<String, Object>> pushToDownstream(Long hotelId, String partnerCode);
/**
* 对指定产品推下游窗口内关房日历(删产品前调用,避免删后无法组报文)。
*/
R<Map<String, Object>> closeProductDailyOnDownstream(Long hotelId, String partnerCode, String productCode,
String roomTypeCode);
R<Map<String, Object>> pullAndPush(Long hotelId, String partnerCode);
R<Map<String, Object>> pullAndPushBatch(List<Long> hotelIds, String partnerCode);
@@ -135,17 +135,107 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
@Override
public R<Boolean> 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<Integer> 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.<SmHotelCallbackTaskEntity>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<SmHotelCallbackTaskEntity> list = taskMapper.selectList(qw);
int n = 0;
for (SmHotelCallbackTaskEntity t : list) {
if (requeueOne(t, "批量重试失败任务入队") == null) {
n++;
}
}
Long remain = taskMapper.selectCount(Wrappers.<SmHotelCallbackTaskEntity>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<Integer> 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
@@ -189,6 +189,14 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
return codes;
}
@Override
public List<String> 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<SmHotelChannelC
if (StrUtil.isNotBlank(dto.getAppSecret())) {
target.setAppSecret(dto.getAppSecret().trim());
}
if (StrUtil.isNotBlank(dto.getAccessKey())) {
target.setAccessKey(dto.getAccessKey().trim());
}
else if (StrUtil.isNotBlank(dto.getKey())) {
target.setAccessKey(dto.getKey().trim());
}
if (dto.getSupplierId() != null) {
target.setSupplierId(dto.getSupplierId());
}
if (StrUtil.isNotBlank(dto.getOrderAppId())) {
target.setOrderAppId(dto.getOrderAppId().trim());
}
if (StrUtil.isNotBlank(dto.getOrderAppSecret())) {
target.setOrderAppSecret(dto.getOrderAppSecret().trim());
}
if (StrUtil.isNotBlank(dto.getOrderNotifyPass())) {
target.setOrderNotifyPass(dto.getOrderNotifyPass().trim());
}
if (StrUtil.isNotBlank(dto.getOrderQueryPath())) {
target.setOrderQueryPath(dto.getOrderQueryPath().trim());
}
if (dto.getTimeoutMs() != null && dto.getTimeoutMs() > 0) {
target.setTimeoutMs(dto.getTimeoutMs());
}
@@ -351,6 +380,9 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (dto.getPushLogRetainDays() != null) {
target.setPushLogRetainDays(dto.getPushLogRetainDays());
}
if (dto.getResourcePushJoinSchedule() != null) {
target.setResourcePushJoinSchedule(dto.getResourcePushJoinSchedule());
}
}
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
@@ -0,0 +1,238 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
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.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.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* 变更后防抖推下游 + 日更范围推。
*/
@Slf4j
@Service
public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAutoPushService {
private static final String TAG = "下游自动推送";
private final HotelDownstreamProperties yamlDefaults;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelChannelDeptService channelDeptService;
private final SmHotelMapper hotelMapper;
private final SmHotelMidService midService;
private final SmHotelSyncService syncService;
public SmHotelDownstreamAutoPushServiceImpl(HotelDownstreamProperties yamlDefaults,
SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService,
SmHotelMapper hotelMapper, @Lazy SmHotelMidService midService, @Lazy SmHotelSyncService syncService) {
this.yamlDefaults = yamlDefaults;
this.channelConfigService = channelConfigService;
this.channelDeptService = channelDeptService;
this.hotelMapper = hotelMapper;
this.midService = midService;
this.syncService = syncService;
}
private final ConcurrentHashMap<Long, ScheduledFuture<?>> 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<String> 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<String> 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<String> partners = resolvePartners(hotel);
if (CollUtil.isEmpty(partners)) {
log.debug("|{}|无命中下游范围|hotelId={}|", TAG, hotelId);
return;
}
for (String partner : partners) {
try {
R<Map<String, Object>> 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<String> resolvePartners(SmHotelEntity hotel) {
List<String> enabled = channelConfigService.listEnabledDownstreamPartners();
if (CollUtil.isEmpty(enabled)) {
return List.of();
}
List<String> 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();
}
}
@@ -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<String, Object> check(String partnerCode, JSONObject data) {
Map<String, Object> outer = ok200();
Map<String, Object> checkBody = toCheckBody(data);
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(checkBody);
Map<String, Object> inner = new LinkedHashMap<>(8);
List<Map<String, Object>> 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<String, Object> d = r.getData();
boolean bookable = Boolean.TRUE.equals(d.get("bookable"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> days = d.get("days") instanceof List<?> list
? (List<Map<String, Object>>) (List<?>) list
: List.of();
String productId = StrUtil.trim(str(data.get("productId")));
boolean full = false;
boolean shortage = false;
for (Map<String, Object> day : days) {
Map<String, Object> 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<String, Object> create(String partnerCode, JSONObject data) {
Map<String, Object> outer = ok200();
String channelOrderNo = StrUtil.trim(str(data.get("channelOrderNo")));
if (StrUtil.isBlank(channelOrderNo)) {
return fail200("channelOrderNo 不能为空");
}
SmHotelOrderEntity exist = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>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<String, Object> 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<Map<String, Object>> 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<String, Object> 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.<SmHotelOrderEntity>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.<SmHotelOrderEntity>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<Boolean> 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<String, Object> outer = ok200();
outer.put("msg", "申请退单成功");
outer.put("data", true);
return outer;
}
@Override
public Map<String, Object> notify(String pass, String ordercode, String childcode) {
Map<String, Object> 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.<SmHotelOrderEntity>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<JSONObject> 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<String, Object> 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<JSONObject> queryRemote(String partnerCode, String ordercode, String childcode) {
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode);
if (props == null || !SmHotelDownstreamProtocol.isHuiding(props.getProtocol())) {
return R.failed("非慧订伙伴");
}
Map<String, Object> 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<Boolean> 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<String, Object> 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.<SmHotelEntity>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.<SmHotelProductEntity>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<String, Object> toCheckBody(JSONObject data) {
Map<String, Object> 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<String, Object> buildCreateData(SmHotelOrderEntity order) {
Map<String, Object> 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<String, Object> ok200() {
Map<String, Object> m = new LinkedHashMap<>(4);
m.put("code", 200);
m.put("msg", "success");
return m;
}
private static Map<String, Object> fail200(String msg) {
Map<String, Object> 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;
}
}
@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> one = new LinkedHashMap<>(6);
Map<String, Object> one = new LinkedHashMap<>(8);
one.put("partnerCode", partner);
one.put("pushKind", SmHotelDownstreamPushKind.SKIP);
one.put("skipped", true);
one.put("skipReason", skipReason);
Map<String, Object> 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<String, Object> one = pushHotelToPartner(hotel, partner);
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String> 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<String, Object> 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.<SmHotelProductEntity>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<String, Object> 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.<SmHotelProductEntity>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.<SmHotelDailyEntity>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.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId()));
if (productCount == null || productCount <= 0) {
return "无产品,无法关房也无法售卖,跳过推送(摘牌前请先关房)";
}
return "跳过推送";
}
/**
* 关房推:环启推 dailyStatus=0;慧订推空价态窗口(对方打满房)。
* @param onlyProducts 非空时只关这些产品(环启);慧订忽略,整窗清空
*/
private Map<String, Object> closeHotelDailyToPartner(SmHotelEntity hotel, String partnerCode,
List<SmHotelProductEntity> 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<SmHotelProductEntity> products = onlyProducts;
if (CollUtil.isEmpty(products)) {
products = productMapper.selectList(Wrappers.<SmHotelProductEntity>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<String, Object> result = new HashMap<>(8);
result.put("productCount", products.size());
result.put("dailyCloseCount", dailyClose);
return result;
}
/** 慧订关房:hoteldatepush 空 roomPriceInfoList,窗口内打满房 */
private Map<String, Object> closeHotelDailyToHuiding(SmHotelEntity hotel, String partnerCode) {
downstreamClient.assertPartnerReady(partnerCode);
HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partnerCode);
LocalDate[] window = resolveDownstreamDailyWindow(partnerCode);
Map<String, Object> 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<String, Object> 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<String, Object> 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<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId));
List<SmHotelProductEntity> products = productMapper.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotelId));
LocalDate[] window = resolveDownstreamDailyWindow(partnerCode);
LocalDate dailyFrom = window[0];
LocalDate dailyTo = window[1];
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotelId)
.ge(SmHotelDailyEntity::getStayDate, dailyFrom)
.le(SmHotelDailyEntity::getStayDate, dailyTo)
.orderByAsc(SmHotelDailyEntity::getProductCode)
.orderByAsc(SmHotelDailyEntity::getStayDate));
Map<String, Object> staticEnvelope = buildHuidingStaticEnvelope(ds, hotel, rooms);
downstreamClient.hotelSync(partnerCode, hotelId, supplierHotelId, staticEnvelope);
List<Map<String, Object>> roomPriceInfoList = buildHuidingRoomPriceInfoList(supplierHotelId, products, dailies);
Map<String, Object> dateEnvelope = buildHuidingDatePushEnvelope(ds, hotel, dailyFrom, dailyTo, roomPriceInfoList);
downstreamClient.dailySync(partnerCode, hotelId, supplierHotelId, dateEnvelope);
Map<String, Object> 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<String, Object> buildHuidingStaticEnvelope(HotelDownstreamProperties ds, SmHotelEntity hotel,
List<SmHotelRoomEntity> rooms) {
String supplierHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId()));
Map<String, Object> 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<Map<String, Object>> roomTypeList = new ArrayList<>();
for (SmHotelRoomEntity room : rooms) {
if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) {
continue;
}
Map<String, Object> 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<String, Object> 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<String, Object> buildHuidingDatePushEnvelope(HotelDownstreamProperties ds, SmHotelEntity hotel,
LocalDate beginNight, LocalDate endNightInclusive, List<Map<String, Object>> roomPriceInfoList) {
String supplierHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId()));
// 文档 endDate 为退房日:最后一晚的次日
LocalDate endCheckout = endNightInclusive.plusDays(1);
Map<String, Object> 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<String, Object> 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<Map<String, Object>> buildHuidingRoomPriceInfoList(String supplierHotelId,
List<SmHotelProductEntity> products, List<SmHotelDailyEntity> dailies) {
Map<String, SmHotelProductEntity> productByCode = new LinkedHashMap<>();
for (SmHotelProductEntity p : products) {
if (p != null && StrUtil.isNotBlank(p.getProductCode())) {
productByCode.put(p.getProductCode(), p);
}
}
Map<String, List<SmHotelDailyEntity>> 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<Map<String, Object>> list = new ArrayList<>();
for (Map.Entry<String, List<SmHotelDailyEntity>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<SmHotelProductEntity> 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<String, Object> 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<String, Object> 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<String, Object> 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<Boolean> 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.<SmHotelDailyEntity>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<DailyDraft> drafts) {
dailyMapper.physicalDeleteFromDate(hotel.getId(), LocalDate.now());
/**
* 日历落库:先按半开区间物理删除再批量插入。
* <p>
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。
* 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。
*/
private int persistDailies(SmHotelEntity hotel, List<DailyDraft> 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;
}
@@ -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<SmHotelMapper, SmHotelEntity>
implements SmHotelService {
@@ -63,6 +63,16 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
private final SmHotelDailyMapper dailyMapper;
private final SmHotelDownstreamAutoPushService downstreamAutoPushService;
public SmHotelServiceImpl(SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper,
SmHotelDailyMapper dailyMapper, @Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService) {
this.roomMapper = roomMapper;
this.productMapper = productMapper;
this.dailyMapper = dailyMapper;
this.downstreamAutoPushService = downstreamAutoPushService;
}
@Override
public Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page,
LambdaQueryWrapper<SmHotelEntity> wrapper) {
@@ -186,7 +196,12 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
entity.setLocalRollHorizonDays(old.getLocalRollHorizonDays());
}
normalizeLocalRoll(entity);
return baseMapper.updateById(entity) > 0;
boolean ok = baseMapper.updateById(entity) > 0;
if (ok) {
// 上架状态/资料变更后防抖推下游(下架走关房推)
downstreamAutoPushService.scheduleAfterHotelChanged(entity.getId());
}
return ok;
}
@Override
@@ -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);
@@ -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<SmHotelEntity> 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);
@@ -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