This commit is contained in:
dzc committed 2026-10-19 13:27:09 +08:00
commit c0d503f00e
81 files changed
+8185 -386

No files matched your search

@@ -0,0 +1,27 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 菜品列表:批量修改上架状态
*/
@Data
@Schema(description = "批量修改菜品上架状态")
public class CanteenSpuBatchStatusDTO {
@NotEmpty
@Schema(description = "菜品 SPU ID 列表")
private List<Long> ids;
/**
* 0=上架,1=下架(与 spu_status 字典一致;不含回收 -1)
*/
@NotNull
@Schema(description = "目标状态:0上架 1下架")
private Integer status;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 仅更新菜品价格(不走 updateDeep,避免清空套餐组成)
*/
@Data
@Schema(description = "更新菜品价格")
public class CanteenSpuPriceDTO {
@NotNull
@Schema(description = "菜品 SPU ID")
private Long id;
@NotNull
@Schema(description = "价格(分)")
private Integer price;
}
@@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.constant.StockTypeEnum;
import com.bsd.pjcloud.canteen.dto.CanteenDailySpuDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuBatchPicDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuBatchStatusDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuPriceDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuExportQuery;
import com.bsd.pjcloud.canteen.dto.CanteenSpuImportExcel;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
@@ -233,6 +235,48 @@ public class CanteenSpuController {
return ok ? R.ok(null, "已更新 " + dto.getIds().size() + " 道菜封面") : R.failed("更新失败");
}
/**
* 批量修改上架状态(0上架 / 1下架)
*/
@Operation(summary = "批量修改菜品上架状态", description = "批量上架或下架")
@SysLog("批量修改菜品上架状态")
@PutMapping("/batchStatus")
@PreAuthorize("@pms.hasPermission('canteen_canteenSpu_edit')")
public R batchStatus(@RequestBody CanteenSpuBatchStatusDTO dto) {
if (CollUtil.isEmpty(dto.getIds())) {
return R.failed("请选择菜品");
}
Integer status = dto.getStatus();
if (status == null || (status != 0 && status != 1)) {
return R.failed("状态只能为上架(0)或下架(1)");
}
boolean ok = canteenSpuService.update(Wrappers.<CanteenSpuEntity>lambdaUpdate()
.set(CanteenSpuEntity::getStatus, status)
.in(CanteenSpuEntity::getId, dto.getIds()));
String label = status == 0 ? "上架" : "下架";
return ok ? R.ok(null, "已" + label + " " + dto.getIds().size() + " 道菜") : R.failed("更新失败");
}
/**
* 仅更新价格,不触碰套餐组成(供每日菜谱「按单品合计」等场景)
*/
@Operation(summary = "更新菜品价格", description = "仅更新价格字段")
@SysLog("更新菜品价格")
@PutMapping("/updatePrice")
@PreAuthorize("@pms.hasPermission('canteen_canteenSpu_edit')")
public R updatePrice(@RequestBody CanteenSpuPriceDTO dto) {
if (dto.getId() == null) {
return R.failed("菜品ID不能为空");
}
if (dto.getPrice() == null || dto.getPrice() < 0) {
return R.failed("价格无效");
}
boolean ok = canteenSpuService.update(Wrappers.<CanteenSpuEntity>lambdaUpdate()
.set(CanteenSpuEntity::getPrice, dto.getPrice())
.eq(CanteenSpuEntity::getId, dto.getId()));
return ok ? R.ok(Boolean.TRUE, "价格已更新") : R.failed("更新失败");
}
/**
* 修改菜品spu
*
@@ -107,7 +107,8 @@ public interface CanteenDailyMenuService extends IService<CanteenDailyMenuEntity
/**
* 按硬件订单净销量回填当日线上/线下 sold(不改配额)。
* <p>口径与 hardware {@code listDailyMenuNetSold} 一致:CANTEEN_DAILY_MENU、
* <p>口径与 hardware {@code listDailyMenuNetSold} 一致:
* App 每日菜谱 {@code CANTEEN_DAILY_MENU} + 消费机线下 {@code CANTEEN_SPU}/order_type=1;
* 已支付/已完成,扣已完成售后;orderType=2→onlineSold,1→offlineSold。
* 当日无对应订单的菜谱行 sold 置 0。
*
@@ -1473,6 +1473,10 @@
每日菜谱库存剩余:按用餐日 appt_use_date 统计净销量。
线上=order_type 2,线下=order_type 1;扣硬售后 status=50 的份数;不含 XFJ。
先按父购物车+商品汇总购买量,再扣退款,避免多行购物车重复扣退。
商品来源:
- App 每日菜谱预约:CANTEEN_DAILY_MENU
- 消费机线下购买:CANTEEN_SPU(createCard 默认来源),实时扣减已按当日菜谱分流到 offline_sold
若只认 CANTEEN_DAILY_MENU,「按订单重算」会把消费机已售全部刷成 0。
-->
<select id="listDailyMenuNetSold" resultType="com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO">
select o.appt_use_date as mealDate,
@@ -1511,7 +1515,10 @@
and o.hour_type not in ('-1')
and o.order_status in ('5', '6', '20')
and o.order_type in ('1', '2')
and p.product_source = 'CANTEEN_DAILY_MENU'
and (
p.product_source = 'CANTEEN_DAILY_MENU'
or (o.order_type = '1' and p.product_source = 'CANTEEN_SPU')
)
<if test="dto.hourTypes != null and dto.hourTypes.size() != 0">
and o.hour_type in
<foreach collection="dto.hourTypes" item="v" open="(" close=")" separator=",">
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.supermarket.constant;
/**
* 酒店第三方渠道方向:上游给我们数据,下游我们推数据帮我们卖。
*/
public final class SmHotelChannelDirection {
/** 上游:供应商 → 本平台 */
public static final String UPSTREAM = "upstream";
/** 下游:本平台 → 销售方 */
public static final String DOWNSTREAM = "downstream";
private SmHotelChannelDirection() {
}
public static String normalize(String direction) {
String d = direction == null ? "" : direction.trim().toLowerCase();
if (DOWNSTREAM.equals(d)) {
return DOWNSTREAM;
}
return UPSTREAM;
}
public static boolean isDownstream(String direction) {
return DOWNSTREAM.equals(normalize(direction));
}
public static boolean isUpstream(String direction) {
return !isDownstream(direction);
}
}
@@ -0,0 +1,76 @@
package com.bsd.pjcloud.supermarket.constant;
import cn.hutool.core.util.StrUtil;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 下游销售方对接协议(按对方公司/系统隔离,与伙伴编码无关)。
* 新下游若接口不同,新增协议常量 + 对应 Adapter 即可。
*/
public final class SmHotelDownstreamProtocol {
/** 环启: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() {
}
public static String normalize(String protocol) {
String p = protocol == null ? "" : protocol.trim().toLowerCase();
if (p.isEmpty() || XIAYOU.equals(p)) {
return HUANQI;
}
if (SHENZHEN.equals(p)) {
return HUIDING;
}
return p;
}
/** 当前已注册可配置的协议(后台下拉) */
public static Set<String> supported() {
Set<String> set = new LinkedHashSet<>();
set.add(HUANQI);
set.add(HUIDING);
return set;
}
public static boolean isSupported(String protocol) {
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;
}
public static String blankToDefault(String protocol) {
return normalize(StrUtil.blankToDefault(StrUtil.trim(protocol), HUANQI));
}
}
@@ -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);
}
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.supermarket.constant;
/**
* 下游推送授权方式。
*/
public final class SmHotelDownstreamScopeType {
/** 按部门(+ 可选位置区域) */
public static final String DEPT = "dept";
/** 按指定酒店 */
public static final String HOTEL = "hotel";
private SmHotelDownstreamScopeType() {
}
public static String normalize(String scopeType) {
String s = scopeType == null ? "" : scopeType.trim().toLowerCase();
if (HOTEL.equals(s)) {
return HOTEL;
}
return DEPT;
}
public static boolean isHotel(String scopeType) {
return HOTEL.equals(normalize(scopeType));
}
public static boolean isDept(String scopeType) {
return !isHotel(scopeType);
}
}
@@ -0,0 +1,97 @@
package com.bsd.pjcloud.supermarket.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 下游销售方对接参数(YAML/JSON 解析后)。
* 环启等下游:本系统作为 A,主动 POST 到 B 的 /third/party/api/*。
*/
@Data
@Schema(description = "下游渠道对接配置")
public class SmHotelDownstreamChannelConfigDTO {
@Schema(description = "协议类型,如 huanqi(可与库表 protocol 字段互为补充)")
private String protocol;
@Schema(description = "下游 B 系统根地址,如 http://host/prod-api")
private String baseUrl;
@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;
@Schema(description = "推送日历天数(与环启约 3 个月窗口取交集)")
private Integer dailyDays;
@Schema(description = "hotel/apartment 每分钟上限,默认 600;≤0 关闭")
private Integer rateLimitHotelApartmentPerMin;
@Schema(description = "product/daily 每分钟上限,默认 300;≤0 关闭")
private Integer rateLimitProductDailyPerMin;
@Schema(description = "order/query 每分钟上限,默认 120;≤0 关闭")
private Integer rateLimitOrderQueryPerMin;
@Schema(description = "order/detail 每分钟上限,默认 600;≤0 关闭")
private Integer rateLimitOrderDetailPerMin;
@Schema(description = "命中限流后最大重试次数,默认 3")
private Integer rateLimitMaxRetries;
@Schema(description = "限流首次退避毫秒,默认 5000")
private Integer rateLimitBackoffMs;
@Schema(description = "是否允许对本伙伴拉订单(手动/定时共用门槛),默认 true")
private Boolean orderPullEnabled;
@Schema(description = "是否参与全局定时拉取;false=仅手动拉(适合文档未要求定时或联调期),默认 false")
private Boolean orderPullJoinSchedule;
@Schema(description = "订单拉取重叠回拨分钟,默认 10")
private Integer orderPullOverlapMinutes;
@Schema(description = "订单拉取每页条数,默认 50")
private Integer orderPullPageSize;
@Schema(description = "推送流水模式:fail_only / sample / all,默认 fail_only")
private String pushLogMode;
@Schema(description = "sample 模式下单次任务最多落库成功条数,默认 20")
private Integer pushLogSampleSuccessMax;
@Schema(description = "请求/响应体落库最大字符,默认 2048;≤0 不截断")
private Integer pushLogBodyMaxChars;
@Schema(description = "推送流水保留天数,默认 15;≤0 不清理")
private Integer pushLogRetainDays;
@Schema(description = "是否参与资源日更定时推送;false=仅手动/变更推,默认 false")
private Boolean resourcePushJoinSchedule;
}
@@ -0,0 +1,105 @@
package com.bsd.pjcloud.supermarket.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 商游(飞猪链路)对接参数:从渠道 config 正文(YAML/JSON)反序列化。
* 键名兼容 kebab-case 与 camelCase;未填字段回落 YAML/Nacos {@code shangyou-hotel.*}。
*/
@Data
@Schema(description = "商游渠道对接参数")
public class SmHotelShangyouChannelConfigDTO {
@Schema(description = "对方根地址,如 https://xxx/link/provider")
private String baseUrl;
@Schema(description = "鉴权根地址;空则用 baseUrl")
private String authBaseUrl;
@Schema(description = "业务根地址;空则用 baseUrl")
private String bizBaseUrl;
private String clientId;
private String clientSecret;
private String cardId;
private String cardPassword;
private String callbackSecret;
private String bookerId;
private Boolean orderIsGroup;
private String orderGuaranteeMode;
private String roomPricePath;
private String memberLevels;
private Integer timeoutMs;
private Integer tokenSkewSeconds;
private Integer priceDays;
private Integer hotelListCacheMinutes;
private Integer hotelListWarnSize;
private Long callbackTenantId;
private Boolean callbackQueueEnabled;
private Integer callbackMaxAttempts;
private Integer callbackTaskIntervalMs;
private Double priceApiQps;
private Integer appPayTimeoutMinutes;
/** 总开关:false 则本服务 Spring Cron 日更全部跳过(测试环境可关) */
private Boolean springScheduleEnabled;
private Boolean hotelListDailySyncEnabled;
private String hotelListDailyCron;
private Boolean priceRollDailyEnabled;
private String priceRollDailyCron;
private Boolean localDailyRollEnabled;
private String localDailyRollCron;
private Boolean autoImportNewHotels;
private Integer autoImportBatchSize;
private Integer priceRollDayOffset;
private Integer priceRollBatchSize;
private Integer priceRollIntervalMs;
private Integer fullPriceSyncMaxPerDay;
private Integer fullPriceSyncMinIntervalHours;
private Integer detailPullIntervalMs;
private Integer detailPullMaxHotels;
private Integer diffScanBatchSize;
private Boolean autoMarkOfflineHotels;
private Boolean autoReactivateOnlineHotels;
private String defaultImportDataSource;
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_channel_config")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店第三方渠道对接配置")
public class SmHotelChannelConfigEntity extends Model<SmHotelChannelConfigEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "渠道标识(上游对齐字典;下游为伙伴编码)")
private String channel;
@Schema(description = "upstream上游 / downstream下游")
private String direction;
@Schema(description = "对接协议(下游必填,如 huanqi=环启;上游可空)")
private String protocol;
@Schema(description = "1启用 0停用")
private String enabled;
@Schema(description = "对接参数正文(YAML 或 JSON,可含注释)")
private String configJson;
private String remark;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -0,0 +1,81 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_channel_dept")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店第三方渠道部门映射")
public class SmHotelChannelDeptEntity extends Model<SmHotelChannelDeptEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "渠道/下游伙伴标识(上游对齐字典;下游为伙伴编码)")
private String channel;
@Schema(description = "upstream上游 / downstream下游")
private String direction;
@Schema(description = "下游授权方式:dept按部门 / hotel按指定酒店(上游固定 dept)")
private String scopeType;
@Schema(description = "部门 ID(上游=归属部门;下游 dept 模式=允许推送的部门)")
private Long deptId;
@Schema(description = "位置区域 adcode(下游 dept 模式可选;空=该部门全部区域)")
private Long districtAdcode;
@Schema(description = "位置区域名称(展示/名称兜底)")
private String district;
@Schema(description = "指定酒店 ID,逗号分隔(下游 hotel 模式)")
private String hotelIds;
@Schema(description = "备注")
private String remark;
@Schema(description = "1启用 0停用")
private String status;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
/** 列表展示:部门名称(非表字段) */
@TableField(exist = false)
@Schema(description = "部门名称")
private String deptName;
/** 列表展示:渠道字典标签(非表字段) */
@TableField(exist = false)
@Schema(description = "渠道名称")
private String channelLabel;
/** 列表展示:指定酒店摘要(非表字段) */
@TableField(exist = false)
@Schema(description = "指定酒店摘要")
private String hotelNames;
}
@@ -0,0 +1,78 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_downstream_push_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店下游推送流水")
public class SmHotelDownstreamPushLogEntity extends Model<SmHotelDownstreamPushLogEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "下游伙伴标识")
private String partnerCode;
@Schema(description = "关联上游同步日志 ID(全量推 DOWNSTREAM_PUSH)")
private Long syncLogId;
@Schema(description = "本系统酒店ID")
private Long hotelId;
@Schema(description = "外部酒店ID")
private String extHotelId;
@Schema(description = "动作:hotel_sync/apartment_sync/product_sync/daily_sync/push_skip")
private String action;
@Schema(description = "完整请求URL")
private String requestUrl;
@Schema(description = "请求体原文")
private String requestBody;
@Schema(description = "响应体原文")
private String responseBody;
@Schema(description = "HTTP状态码")
private Integer httpStatus;
@Schema(description = "业务code")
private Integer bizCode;
@Schema(description = "1成功 0失败 2跳过(下架/无产品/无日历等预检)")
private String success;
@Schema(description = "失败摘要")
private String errorMsg;
@Schema(description = "耗时毫秒")
private Long costMs;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -25,6 +25,9 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
private Long downstreamAppId;
@Schema(description = "下游伙伴编码(环启等)")
private String partnerCode;
private Long hotelId;
@Schema(description = "下游渠道订单号")
@@ -91,6 +94,18 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
private String reqJson;
@Schema(description = "环启报文 changeTime")
private LocalDateTime changeTime;
@Schema(description = "环启渠道 status")
private Integer channelStatus;
@Schema(description = "环启 orderStatus")
private Integer channelOrderStatus;
@Schema(description = "环启订单原文 JSON")
private String huanqiPayload;
private String upstreamRespJson;
@TableField(fill = FieldFill.INSERT)
@@ -124,7 +139,7 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@Schema(description = "下单来源")
private String orderSource;
/** 列表展示:酒店数据来源 local/shangyou(非表字段) */
/** 列表展示:酒店数据来源 local/shangyou/其它渠道(非表字段) */
@TableField(exist = false)
@Schema(description = "酒店数据来源")
private String hotelDataSource;
@@ -0,0 +1,61 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_order_upstream_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店订单上游调用流水")
public class SmHotelOrderUpstreamLogEntity extends Model<SmHotelOrderUpstreamLogEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "订单 ID")
private Long orderId;
@Schema(description = "渠道 local/shangyou/...")
private String channel;
@Schema(description = "CREATE/QUERY/CANCEL/RETRY_ADD/AUTO_REFUND/CHECK_AVAIL")
private String action;
@Schema(description = "1成功 0失败")
private String success;
private String bizCode;
private String message;
private String reqJson;
private String respJson;
private Long costMs;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.vo;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -40,6 +41,9 @@ public class SmHotelOrderDetailVo {
@Schema(description = "上游响应快照(解析自 upstreamRespJson)")
private Object upstreamResp;
@Schema(description = "上游调用流水(CREATE/QUERY/CANCEL/AUTO_REFUND 等)")
private List<SmHotelOrderUpstreamLogEntity> upstreamLogs;
@Data
@Schema(description = "酒店摘要")
public static class HotelBrief {
@@ -52,7 +56,7 @@ public class SmHotelOrderDetailVo {
private String groupCode;
/** local / shangyou */
/** 数据来源:local / shangyou / 其它渠道 sheet_name */
private String dataSource;
private String upstreamStatus;
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
/**
* 下游销售方推送配置(本系统作为 A,主动推送到 B 的 /third/party/api/*)。
* 优先读库表「渠道对接配置」direction=downstream 的 YAML;本类作 YAML/Nacos 单下游兼容回落。
*/
@Data
@Component
@@ -15,17 +16,130 @@ public class HotelDownstreamProperties {
/** 是否启用向下游推送 */
private boolean enabled = false;
/**
* 对接协议:huanqi(环启)等,决定走哪个 Adapter。
* @see com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol
*/
private String protocol = "huanqi";
/** 下游 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个月) */
/** 拉取上游后自动生成未来日历天数(默认 90≈3个月,与环启可改窗口取交集) */
private int dailyDays = 90;
/** /hotel/sync、/apartment/sync 每分钟上限(环启文档 600) */
private int rateLimitHotelApartmentPerMin = 600;
/** /product/sync、/daily/sync 每分钟上限(环启文档 300) */
private int rateLimitProductDailyPerMin = 300;
/** /order/query 每分钟上限(环启文档 120) */
private int rateLimitOrderQueryPerMin = 120;
/** /order/detail 每分钟上限(环启文档 600) */
private int rateLimitOrderDetailPerMin = 600;
/** 命中「访问过于频繁」后最大重试次数 */
private int rateLimitMaxRetries = 3;
/** 限流首次退避毫秒(之后指数翻倍) */
private int rateLimitBackoffMs = 5000;
/**
* 是否允许对本伙伴拉订单(手动按钮与定时共用门槛)。
* false:既不能手动也不能定时。
*/
private boolean orderPullEnabled = true;
/**
* 是否参与全局定时拉取。
* false(默认):只靠 PC「手动拉取」或推送接收,适合联调/文档未要求定时的伙伴;
* true:全局 cron 到点时会拉这一家。不同协议/不同家文档可各自开关。
*/
private boolean orderPullJoinSchedule = false;
/** 增量水位回拨分钟(环启文档建议 10;其它伙伴可按各自文档改) */
private int orderPullOverlapMinutes = 10;
/** 订单拉取 pageSize */
private int orderPullPageSize = 50;
/** Spring 定时拉取 cron(进程级时钟);到点后只拉 join-schedule=true 的伙伴;改时刻需重启 */
private String orderPullCron = "0 */5 * * * ?";
/** 进程级总开关:false 时全局定时不跑(各伙伴手动拉取仍可用) */
private boolean orderPullScheduleEnabled = true;
/**
* 推送流水落库模式:
* fail_only(默认)= 只记失败;sample = 失败全记 + 成功抽样;all = 每次 HTTP 都记(联调用)。
*/
private String pushLogMode = "fail_only";
/** sample 模式下,单次任务/上下文内最多落库的成功条数(失败不受限) */
private int pushLogSampleSuccessMax = 20;
/** 请求/响应体落库最大字符数,超出截断;≤0 不截断 */
private int pushLogBodyMaxChars = 2048;
/** 推送流水保留天数,定时清理更早数据;≤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 * * ?";
}
@@ -44,13 +44,45 @@ public class SmHotelCallbackTaskController {
}
@SysLog("酒店回调任务重试")
@Operation(summary = "失败/跳过任务重新入队")
@Operation(summary = "失败/跳过/待处理任务重新入队")
@PostMapping("/retry/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')")
public R<Boolean> retry(@PathVariable Long id) {
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")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_retry')")
public R<Integer> requeuePending() {
int n = callbackTaskService.requeueStalePending();
return R.ok(n, "已重新入队 " + n + " 条(含超时 RUNNING 恢复)");
}
@SysLog("删除酒店回调任务")
@Operation(summary = "删除单条回调任务")
@DeleteMapping("/{id}")
@@ -0,0 +1,108 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelChannelConfig")
@Tag(name = "酒店第三方渠道对接配置(YAML/JSON 热更新,区分上下游)")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelChannelConfigController {
private final SmHotelChannelConfigService channelConfigService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<Page<SmHotelChannelConfigEntity>> page(@ParameterObject Page<SmHotelChannelConfigEntity> page,
@RequestParam(required = false) String channel,
@RequestParam(required = false) String direction) {
return R.ok(channelConfigService.pageConfigs(page, channel, direction));
}
@Operation(summary = "按渠道+方向查询")
@GetMapping("/byChannel")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<SmHotelChannelConfigEntity> byChannel(@RequestParam String channel,
@RequestParam(required = false) String direction) {
return R.ok(channelConfigService.getByChannel(channel, direction));
}
@Operation(summary = "上游 YAML 默认值(表单占位对照)")
@GetMapping("/yamlDefaults")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<ShangyouHotelProperties> yamlDefaults() {
return R.ok(channelConfigService.yamlDefaultsSnapshot());
}
@Operation(summary = "下游 YAML 默认值(表单占位对照)")
@GetMapping("/downstreamYamlDefaults")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<HotelDownstreamProperties> downstreamYamlDefaults() {
return R.ok(channelConfigService.downstreamYamlDefaultsSnapshot());
}
@Operation(summary = "已实现的下游协议列表")
@GetMapping("/downstreamProtocols")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<java.util.List<String>> downstreamProtocols() {
return R.ok(channelConfigService.listDownstreamProtocols());
}
@Operation(summary = "已配置的下游伙伴列表(范围映射下拉)")
@GetMapping("/downstreamPartners")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<java.util.List<SmHotelChannelConfigEntity>> downstreamPartners() {
return R.ok(channelConfigService.listDownstreamPartners());
}
@Operation(summary = "当前生效的商游运行时配置(库表覆盖后)")
@GetMapping("/shangyouRuntime")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<ShangyouHotelProperties> shangyouRuntime() {
return R.ok(channelConfigService.resolveShangyouRuntime());
}
@SysLog("保存酒店渠道对接配置")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> save(@RequestBody SmHotelChannelConfigEntity entity) {
return channelConfigService.saveOrUpdateByChannel(entity);
}
@SysLog("修改酒店渠道对接配置")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> update(@RequestBody SmHotelChannelConfigEntity entity) {
return channelConfigService.saveOrUpdateByChannel(entity);
}
@SysLog("删除酒店渠道对接配置")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> remove(@RequestBody Long[] ids) {
return channelConfigService.removeByIdsSafe(ids);
}
@SysLog("刷新渠道对接配置缓存")
@PostMapping("/evictCache")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> evictCache(@RequestParam(required = false) String channel) {
channelConfigService.evictCache(channel);
return R.ok(Boolean.TRUE);
}
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelChannelDept")
@Tag(name = "酒店第三方渠道部门映射")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelChannelDeptController {
private final SmHotelChannelDeptService channelDeptService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_view')")
public R<Page<SmHotelChannelDeptEntity>> page(@ParameterObject Page<SmHotelChannelDeptEntity> page,
@ParameterObject SmHotelChannelDeptEntity query) {
return R.ok(channelDeptService.pageMappings(page, query));
}
@Operation(summary = "启用中的渠道部门(同步/拉取下拉)")
@GetMapping("/listEnabled")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync') || @pms.hasPermission('supermarket_smHotelChannelDept_view') || @pms.hasPermission('supermarket_smHotel_add')")
public R<List<SmHotelChannelDeptEntity>> listEnabled() {
return R.ok(channelDeptService.listEnabled());
}
@SysLog("新增酒店渠道部门映射")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_add')")
public R<Boolean> save(@RequestBody SmHotelChannelDeptEntity entity) {
return channelDeptService.saveMapping(entity);
}
@SysLog("修改酒店渠道部门映射")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_edit')")
public R<Boolean> update(@RequestBody SmHotelChannelDeptEntity entity) {
return channelDeptService.updateMapping(entity);
}
@SysLog("删除酒店渠道部门映射")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_del')")
public R<Boolean> remove(@RequestBody Long[] ids) {
return channelDeptService.removeMappings(ids);
}
}
@@ -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,85 @@
package com.bsd.pjcloud.supermarket.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelDownstreamPushLog")
@Tag(name = "酒店下游推送流水")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelDownstreamPushLogController {
private final SmHotelDownstreamPushLogService pushLogService;
private final SmHotelMidService midService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_view')")
public R<Page<SmHotelDownstreamPushLogEntity>> page(@ParameterObject Page<SmHotelDownstreamPushLogEntity> page,
@ParameterObject SmHotelDownstreamPushLogEntity query) {
return R.ok(pushLogService.pageLogs(page, query));
}
@Operation(summary = "详情")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_view')")
public R<SmHotelDownstreamPushLogEntity> detail(@PathVariable Long id) {
return R.ok(pushLogService.getById(id));
}
@SysLog("下游推送流水重试")
@Operation(summary = "按流水关联酒店重新推送到下游(整店:酒店/房型/产品/日历)")
@PostMapping("/retry/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_retry')")
public R<Map<String, Object>> retry(@PathVariable Long id) {
SmHotelDownstreamPushLogEntity row = pushLogService.getById(id);
if (row == null) {
return R.failed("流水不存在");
}
if (row.getHotelId() == null) {
return R.failed("流水缺少酒店ID,无法重推");
}
if (StrUtil.isBlank(row.getPartnerCode())) {
return R.failed("流水缺少下游伙伴,无法重推");
}
String action = StrUtil.blankToDefault(row.getAction(), "");
if ("order_query".equalsIgnoreCase(action) || "order_detail".equalsIgnoreCase(action)) {
return R.failed("订单查询类流水不支持从此处重推,请使用订单对接能力");
}
return midService.pushToDownstream(row.getHotelId(), row.getPartnerCode().trim());
}
@SysLog("删除下游推送流水")
@Operation(summary = "删除单条推送流水")
@DeleteMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_del')")
public R<Boolean> remove(@PathVariable Long id) {
return pushLogService.removeLog(id);
}
@SysLog("按时间区间删除下游推送流水")
@Operation(summary = "按创建时间区间删除推送流水")
@PostMapping("/range")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelDownstreamPushLog_del')")
public R<Integer> removeRange(@RequestParam(required = false) String createTimeBegin,
@RequestParam(required = false) String createTimeEnd) {
return pushLogService.removeByCreateTimeRange(createTimeBegin, createTimeEnd);
}
}
@@ -0,0 +1,129 @@
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.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamInboundAuth;
import com.bsd.pjcloud.supermarket.service.SmHotelHuanqiOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 环启订单:B→A 推送接收;A→B 拉取(管理端触发)。
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@Tag(name = "环启酒店订单对接")
public class SmHotelHuanqiOrderController {
private final DownstreamInboundAuth inboundAuth;
private final SmHotelHuanqiOrderService huanqiOrderService;
private final SmHotelOrderService hotelOrderService;
/**
* 销售前二次校验(环启文档无此接口,我方补充):下单前查本地酒店/产品/上架/日历库存/可选验价。
* 请环启在成交前调用;返回 data.bookable=true 再售卖。
*/
@Inner(value = false)
@Operation(summary = "下游下单前可订校验")
@PostMapping("/third/party/api/order/check")
public Map<String, Object> checkBookable(HttpServletRequest request, @RequestBody String rawBody) {
Map<String, Object> resp = new LinkedHashMap<>(4);
try {
String appKey = header(request, "appKey");
inboundAuth.verifyForHuanqi(appKey, header(request, "timestamp"), header(request, "nonce"),
header(request, "sign"), rawBody);
JSONObject payload = JSONUtil.parseObj(rawBody);
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(payload);
if (r != null && r.getCode() == 0) {
resp.put("code", 200);
resp.put("msg", "success");
resp.put("data", r.getData());
return resp;
}
resp.put("code", 500);
resp.put("msg", r == null ? "校验失败" : r.getMsg());
return resp;
}
catch (Exception e) {
log.warn("|环启订单预检|失败|{}", e.getMessage());
resp.put("code", 500);
resp.put("msg", StrUtil.blankToDefault(e.getMessage(), "校验失败"));
return resp;
}
}
@Inner(value = false)
@Operation(summary = "接收环启订单推送(push/update/cancel)")
@PostMapping("/third/party/api/order/push")
public Map<String, Object> receivePush(HttpServletRequest request, @RequestBody String rawBody) {
Map<String, Object> resp = new LinkedHashMap<>(4);
try {
String appKey = header(request, "appKey");
inboundAuth.verifyForHuanqi(appKey, header(request, "timestamp"), header(request, "nonce"),
header(request, "sign"), rawBody);
String partner = huanqiOrderService.resolvePartnerByAppKey(appKey);
if (StrUtil.isBlank(partner)) {
resp.put("code", 500);
resp.put("msg", "appKey 未匹配到启用下游伙伴");
return resp;
}
JSONObject payload = JSONUtil.parseObj(rawBody);
R<Map<String, Object>> r = huanqiOrderService.applyPayload(partner, payload);
if (r != null && r.getCode() == 0) {
resp.put("code", 200);
resp.put("msg", "success");
return resp;
}
resp.put("code", 500);
resp.put("msg", r == null ? "处理失败" : r.getMsg());
return resp;
}
catch (Exception e) {
log.warn("|环启订单推送|接收失败|{}", e.getMessage());
resp.put("code", 500);
resp.put("msg", StrUtil.blankToDefault(e.getMessage(), "处理失败"));
return resp;
}
}
@SysLog("手动拉取环启订单")
@Operation(summary = "手动拉取指定下游伙伴订单增量")
@PostMapping("/smHotel/mid/huanqiOrder/pull")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public R<Map<String, Object>> pullOne(@RequestParam("partnerCode") String partnerCode) {
return huanqiOrderService.pullIncremental(partnerCode);
}
@SysLog("手动拉取全部环启订单")
@Operation(summary = "手动拉取全部启用下游的订单增量")
@PostMapping("/smHotel/mid/huanqiOrder/pullAll")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public R<Map<String, Object>> pullAll() {
return huanqiOrderService.pullAllEnabledPartners();
}
private static String header(HttpServletRequest request, String name) {
String v = request.getHeader(name);
return v != null ? v : request.getParameter(name);
}
}
@@ -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;
}
}
@@ -7,6 +7,7 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
@@ -39,6 +40,8 @@ public class SmHotelMidController {
private final SmHotelSyncService syncService;
private final SmHotelChannelDeptService channelDeptService;
@Operation(summary = "查询全局售价调整配置(加价/折扣)")
@GetMapping("/priceConfig")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
@@ -106,25 +109,64 @@ public class SmHotelMidController {
return midService.pullProductsFromUpstream(id);
}
@Operation(summary = "推送到下游销售方")
@Operation(summary = "已启用的下游伙伴(推送前下拉)")
@GetMapping("/mid/downstreamPartners")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<java.util.List<java.util.Map<String, Object>>> enabledDownstreamPartners() {
return midService.listEnabledDownstreamPartnerOptions();
}
@Operation(summary = "推送到下游销售方(须指定伙伴)")
@PostMapping("/mid/push/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> push(@PathVariable Long id) {
return midService.pushToDownstream(id);
public R<Map<String, Object>> push(@PathVariable Long id,
@RequestParam("partnerCode") String partnerCode) {
return midService.pushToDownstream(id, partnerCode);
}
@Operation(summary = "拉取上游并推送下游")
@Operation(summary = "拉取上游并推送下游(须指定伙伴)")
@PostMapping("/mid/pullAndPush/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullAndPush(@PathVariable Long id) {
return midService.pullAndPush(id);
public R<Map<String, Object>> pullAndPush(@PathVariable Long id,
@RequestParam("partnerCode") String partnerCode) {
return midService.pullAndPush(id, partnerCode);
}
@Operation(summary = "批量拉取并推送")
@Operation(summary = "批量拉取并推送(须指定伙伴)")
@PostMapping("/mid/pullAndPush/batch")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullAndPushBatch(@RequestBody List<Long> ids) {
return midService.pullAndPushBatch(ids);
public R<Map<String, Object>> pullAndPushBatch(@RequestParam("partnerCode") String partnerCode,
@RequestBody List<Long> ids) {
return midService.pullAndPushBatch(ids, partnerCode);
}
@Operation(summary = "预览下游范围映射命中的上架酒店数量")
@GetMapping("/mid/pushScope/preview")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> previewPushScope(@RequestParam("partnerCode") String partnerCode) {
List<Long> ids = channelDeptService.listHotelIdsForDownstreamPartner(partnerCode);
Map<String, Object> data = new LinkedHashMap<>(4);
data.put("partnerCode", partnerCode);
data.put("hotelCount", ids == null ? 0 : ids.size());
return R.ok(data);
}
@SysLog("按下游范围全量推送")
@Operation(summary = "按渠道部门映射异步全量推送到指定下游(只推本地数据,立刻返回 logId)")
@PostMapping("/mid/pushScope")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pushScope(@RequestParam("partnerCode") String partnerCode) {
R<SmHotelSyncLogEntity> started = syncService.startDownstreamScopePushAsync(partnerCode, "MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("partnerCode", partnerCode);
data.put("message", "已提交,请到「上游同步日志」查看进度");
return R.ok(data, started.getMsg());
}
@Operation(summary = "查询上游账号已授权酒店列表")
@@ -298,12 +340,13 @@ public class SmHotelMidController {
}
@SysLog("全量拉取启用酒店详情")
@Operation(summary = "异步拉取全部启用酒店详情(房型+产品+未来房价),立刻返回 logId")
@Operation(summary = "异步拉取指定渠道启用酒店详情(房型+产品+未来房价),立刻返回 logId")
@PostMapping("/mid/pullAllActiveDetails")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullAllActiveDetails(
@RequestParam(required = false) Integer limit) {
R<SmHotelSyncLogEntity> started = syncService.startDetailPullAsync(limit, "MANUAL");
@RequestParam(required = false) Integer limit,
@RequestParam("dataSource") String dataSource) {
R<SmHotelSyncLogEntity> started = syncService.startDetailPullAsync(limit, "MANUAL", dataSource);
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
@@ -311,6 +354,7 @@ public class SmHotelMidController {
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("dataSource", dataSource);
data.put("message", "已提交,请轮询 /mid/syncLog/{id} 或打开同步日志");
return R.ok(data, started.getMsg());
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.supermarket.event;
import lombok.Getter;
/**
* 上游硬失败后、事务提交再触发原路退款(避免嵌在支付回调长事务内)。
*/
@Getter
public class HotelUpstreamFailRefundEvent {
private final Long orderId;
private final Long tenantId;
private final String reason;
public HotelUpstreamFailRefundEvent(Long orderId, Long tenantId, String reason) {
this.orderId = orderId;
this.tenantId = tenantId;
this.reason = reason;
}
}
@@ -0,0 +1,77 @@
package com.bsd.pjcloud.supermarket.event;
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.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 支付后上游硬失败:提交后自动原路退款,并写 AUTO_REFUND 流水。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotelUpstreamFailRefundListener {
private final SmHotelOrderService orderService;
private final SmHotelOrderMapper orderMapper;
private final SmHotelOrderUpstreamLogService upstreamLogService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onUpstreamFail(HotelUpstreamFailRefundEvent event) {
if (event == null || event.getOrderId() == null) {
return;
}
Long tenantId = event.getTenantId() != null ? event.getTenantId() : TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = 1L;
}
Long tid = tenantId;
try {
TenantBroker.runAs(tid, id -> doRefund(event));
}
catch (Exception e) {
log.error("|酒店自动退款|异常|orderId={}|", event.getOrderId(), e);
markRefundNeedManual(event.getOrderId(), "自动退款异常: " + e.getMessage());
}
}
private void doRefund(HotelUpstreamFailRefundEvent event) {
String reason = StrUtil.blankToDefault(event.getReason(), "上游下单失败自动退款");
long t0 = System.currentTimeMillis();
R<Boolean> res = orderService.refundByAdmin(event.getOrderId(), reason);
long cost = System.currentTimeMillis() - t0;
boolean ok = res != null && res.isOk();
upstreamLogService.saveSimple(event.getOrderId(), "auto", "AUTO_REFUND", ok,
ok ? "OK" : "FAIL", ok ? "自动退款已受理" : (res == null ? "退款无响应" : res.getMsg()),
null, null, cost);
if (!ok) {
markRefundNeedManual(event.getOrderId(),
"上游失败且自动退款失败: " + (res == null ? "无响应" : StrUtil.blankToDefault(res.getMsg(), "未知")));
}
log.info("|酒店自动退款|完成|orderId={}|ok={}|msg={}|", event.getOrderId(), ok,
res == null ? null : res.getMsg());
}
private void markRefundNeedManual(Long orderId, String msg) {
SmHotelOrderEntity order = orderMapper.selectById(orderId);
if (order == null) {
return;
}
order.setCompensateStatus("NEED_MANUAL");
order.setFailMsg(StrUtil.sub(StrUtil.blankToDefault(order.getFailMsg(), "") + ";" + msg, 0, 500));
order.setLastCompensateMsg(StrUtil.sub(msg, 0, 500));
orderMapper.updateById(order);
}
}
@@ -0,0 +1,37 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.json.JSONObject;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
/**
* 下游酒店推送协议适配器。一种协议一份实现,互不影响。
*/
public interface DownstreamHotelAdapter {
/** 协议标识,对齐 {@link com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol} */
String protocol();
void assertReady(String partnerCode, HotelDownstreamProperties props);
JSONObject hotelSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body);
JSONObject apartmentSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body);
JSONObject productSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body);
JSONObject dailySync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body);
/** 订单增量/对账拉取(A→B);不支持的协议可抛异常 */
default JSONObject orderQuery(String partnerCode, HotelDownstreamProperties props, Object body) {
throw new UnsupportedOperationException("协议 " + protocol() + " 未实现 orderQuery");
}
/** 订单单笔详情(A→B) */
default JSONObject orderDetail(String partnerCode, HotelDownstreamProperties props, Object body) {
throw new UnsupportedOperationException("协议 " + protocol() + " 未实现 orderDetail");
}
}
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 按协议路由下游适配器;新协议只需新增 Adapter Bean。
*/
@Component
public class DownstreamHotelAdapterRegistry {
private final Map<String, DownstreamHotelAdapter> byProtocol = new LinkedHashMap<>();
public DownstreamHotelAdapterRegistry(List<DownstreamHotelAdapter> adapters) {
if (adapters != null) {
for (DownstreamHotelAdapter adapter : adapters) {
if (adapter == null || StrUtil.isBlank(adapter.protocol())) {
continue;
}
String key = SmHotelDownstreamProtocol.normalize(adapter.protocol());
byProtocol.put(key, adapter);
}
}
}
public DownstreamHotelAdapter require(String protocol) {
String key = SmHotelDownstreamProtocol.normalize(protocol);
DownstreamHotelAdapter adapter = byProtocol.get(key);
if (adapter == null) {
throw new CheckedException("下游协议「" + key + "」尚未实现适配器,已注册:"
+ String.join(",", byProtocol.keySet()));
}
return adapter;
}
public Collection<String> registeredProtocols() {
return byProtocol.keySet();
}
}
@@ -15,4 +15,10 @@ public final class DownstreamHotelApi {
public static final String PRODUCT_SYNC = "/third/party/api/product/sync";
public static final String DAILY_SYNC = "/third/party/api/daily/sync";
/** 订单增量/对账拉取(A→B) */
public static final String ORDER_QUERY = "/third/party/api/order/query";
/** 订单单笔详情(A→B) */
public static final String ORDER_DETAIL = "/third/party/api/order/detail";
}
@@ -1,97 +1,77 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.IdUtil;
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.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 向下游销售方(B系统)推送酒店/房型/产品/日历房。
* 下游推送门面:按伙伴配置的 protocol 路由到对应适配器。
* 业务层只认伙伴编码,不关心对方系统差异。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DownstreamHotelClient {
private static final String TAG = "下游酒店推送";
private final SmHotelChannelConfigService channelConfigService;
private final HotelDownstreamProperties properties;
private final DownstreamHotelAdapterRegistry adapterRegistry;
public void assertEnabled() {
if (!properties.isEnabled()) {
throw new CheckedException("下游推送未启用,请配置 hotel-downstream.enabled=true");
}
if (StrUtil.isBlank(properties.getBaseUrl()) || StrUtil.isBlank(properties.getAppKey())
|| StrUtil.isBlank(properties.getAppSecret())) {
throw new CheckedException("下游推送配置不完整:base-url / app-key / app-secret");
}
public void assertPartnerReady(String partnerCode) {
HotelDownstreamProperties props = resolve(partnerCode);
adapter(props).assertReady(partnerCode, props);
}
public JSONObject hotelSync(Object body) {
return post(DownstreamHotelApi.HOTEL_SYNC, body);
public HotelDownstreamProperties resolve(String partnerCode) {
return channelConfigService.resolveDownstreamRuntime(partnerCode);
}
public JSONObject apartmentSync(Object body) {
return post(DownstreamHotelApi.APARTMENT_SYNC, body);
public JSONObject hotelSync(String partnerCode, Long hotelId, String extHotelId, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
return adapter(props).hotelSync(partnerCode, props, hotelId, extHotelId, body);
}
public JSONObject productSync(Object body) {
return post(DownstreamHotelApi.PRODUCT_SYNC, body);
public JSONObject apartmentSync(String partnerCode, Long hotelId, String extHotelId, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
return adapter(props).apartmentSync(partnerCode, props, hotelId, extHotelId, body);
}
public JSONObject dailySync(Object body) {
return post(DownstreamHotelApi.DAILY_SYNC, body);
public JSONObject productSync(String partnerCode, Long hotelId, String extHotelId, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
return adapter(props).productSync(partnerCode, props, hotelId, extHotelId, body);
}
private JSONObject post(String path, Object bodyObj) {
assertEnabled();
String bodyJson = bodyObj instanceof String ? (String) bodyObj : JSONUtil.toJsonStr(bodyObj);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = DownstreamSignHelper.sign(properties.getAppKey(), properties.getAppSecret(), timestamp, nonce,
bodyJson);
String url = trimSlash(properties.getBaseUrl()) + path;
log.info("|{}|req|path={}|body={}|", TAG, path, bodyJson);
try (HttpResponse response = HttpRequest.post(url)
.timeout(properties.getTimeoutMs())
.header("Content-Type", "application/json;charset=UTF-8")
.header("appKey", properties.getAppKey())
.header("timestamp", timestamp)
.header("nonce", nonce)
.header("sign", sign)
.body(bodyJson)
.execute()) {
String res = response.body();
log.info("|{}|res|path={}|http={}|body={}|", TAG, path, response.getStatus(), res);
if (StrUtil.isBlank(res)) {
throw new CheckedException("下游响应为空: " + path);
}
JSONObject json = JSONUtil.parseObj(res);
Integer code = json.getInt("code");
if (code == null || code != 200) {
throw new CheckedException(StrUtil.format("下游失败[{}] code={} msg={}", path, code, json.getStr("msg")));
}
return json;
}
catch (CheckedException e) {
throw e;
}
catch (Exception e) {
throw new CheckedException("下游调用失败: " + e.getMessage());
public JSONObject dailySync(String partnerCode, Long hotelId, String extHotelId, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
return adapter(props).dailySync(partnerCode, props, hotelId, extHotelId, body);
}
public JSONObject orderQuery(String partnerCode, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
return adapter(props).orderQuery(partnerCode, props, body);
}
public JSONObject orderDetail(String partnerCode, Object body) {
HotelDownstreamProperties props = resolve(partnerCode);
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 static String trimSlash(String base) {
if (base == null) {
return "";
}
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
private DownstreamHotelAdapter adapter(HotelDownstreamProperties props) {
String protocol = props == null ? SmHotelDownstreamProtocol.HUANQI : props.getProtocol();
return adapterRegistry.require(protocol);
}
}
@@ -1,15 +1,23 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
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;
/**
@@ -21,12 +29,66 @@ 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;
private final SmHotelChannelConfigService channelConfigService;
public SmHotelDownstreamAppEntity verify(String appKey, String timestamp, String nonce, String sign,
String bodyJson) {
assertCommon(appKey, timestamp, nonce, sign);
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
if (app == null) {
throw new CheckedException("appKey 无效");
}
assertSign(appKey, app.getAppSecret(), timestamp, nonce, sign, bodyJson);
return app;
}
/**
* 环启 B→A 推送验签:appKey/secret 与渠道对接配置(下游 YAML)一致。
*/
public void verifyForHuanqi(String appKey, String timestamp, String nonce, String sign, String bodyJson) {
assertCommon(appKey, timestamp, nonce, sign);
String secret = null;
for (String code : channelConfigService.listEnabledDownstreamPartners()) {
HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code);
if (p != null && appKey.equals(p.getAppKey()) && StrUtil.isNotBlank(p.getAppSecret())) {
secret = p.getAppSecret();
break;
}
}
if (secret == null) {
HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot();
if (def != null && def.isEnabled() && appKey.equals(def.getAppKey())) {
secret = def.getAppSecret();
}
}
if (StrUtil.isBlank(secret)) {
// 兼容旧表
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
if (app != null) {
secret = app.getAppSecret();
}
}
if (StrUtil.isBlank(secret)) {
throw new CheckedException("appKey 无效");
}
assertSign(appKey, secret, timestamp, nonce, sign, bodyJson);
}
private void assertCommon(String appKey, String timestamp, String nonce, String sign) {
if (StrUtil.isBlank(appKey) || StrUtil.isBlank(timestamp) || StrUtil.isBlank(nonce) || StrUtil.isBlank(sign)) {
throw new CheckedException("缺少鉴权参数");
}
@@ -40,21 +102,88 @@ public class DownstreamInboundAuth {
if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000L) {
throw new CheckedException("签名过期");
}
Boolean ok = redisTemplate.opsForValue().setIfAbsent(NONCE_KEY + appKey + ":" + nonce, "1", 5, TimeUnit.MINUTES);
Boolean ok = redisTemplate.opsForValue().setIfAbsent(NONCE_KEY + appKey + ":" + nonce, "1", 5,
TimeUnit.MINUTES);
if (Boolean.FALSE.equals(ok)) {
throw new CheckedException("nonce 重复");
}
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
if (app == null) {
throw new CheckedException("appKey 无效");
}
String expect = DownstreamSignHelper.sign(appKey, app.getAppSecret(), timestamp, nonce, bodyJson);
}
private static void assertSign(String appKey, String appSecret, String timestamp, String nonce, String sign,
String bodyJson) {
String expect = DownstreamSignHelper.sign(appKey, appSecret, timestamp, nonce, bodyJson);
if (!expect.equalsIgnoreCase(sign)) {
throw new CheckedException("签名错误");
}
return app;
}
/**
* 慧订订单入站验签(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,52 @@
package com.bsd.pjcloud.supermarket.integration.downstream;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 下游推送落库上下文:挂在全量推/单店推线程上,关联同步日志并做成功抽样计数。
*/
public final class DownstreamPushLogContext {
private static final ThreadLocal<Holder> TL = new ThreadLocal<>();
private DownstreamPushLogContext() {
}
public static void begin(Long syncLogId, int sampleSuccessMax) {
Holder h = new Holder();
h.syncLogId = syncLogId;
h.sampleSuccessMax = Math.max(0, sampleSuccessMax);
TL.set(h);
}
public static void end() {
TL.remove();
}
public static Long syncLogId() {
Holder h = TL.get();
return h == null ? null : h.syncLogId;
}
public static boolean hasContext() {
return TL.get() != null;
}
/** 成功抽样是否还能再记一条(失败不应调用) */
public static boolean tryConsumeSuccessSample() {
Holder h = TL.get();
if (h == null) {
return false;
}
if (h.sampleSuccessMax <= 0) {
return false;
}
return h.successLogged.incrementAndGet() <= h.sampleSuccessMax;
}
private static final class Holder {
private Long syncLogId;
private int sampleSuccessMax;
private final AtomicInteger successLogged = new AtomicInteger(0);
}
}
@@ -0,0 +1,325 @@
package com.bsd.pjcloud.supermarket.integration.downstream.huanqi;
import cn.hutool.core.util.IdUtil;
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.DownstreamHotelApi;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogContext;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamSignHelper;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 环启下游协议:本系统作为 A,POST B 的 /third/party/api/* + MD5 签名。
* 含按 appKey 的分钟限流,以及「访问过于频繁」退避重试。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter {
private static final String TAG = "下游推送[环启]";
private static final String RATE_KEY = "sm:hotel:huanqi:rate:";
private final SmHotelDownstreamPushLogService pushLogService;
private final StringRedisTemplate redisTemplate;
@Override
public String protocol() {
return SmHotelDownstreamProtocol.HUANQI;
}
@Override
public void assertReady(String partnerCode, HotelDownstreamProperties props) {
if (props == null || !props.isEnabled()) {
throw new CheckedException("下游伙伴未启用: " + partnerCode);
}
if (StrUtil.isBlank(props.getBaseUrl()) || StrUtil.isBlank(props.getAppKey())
|| StrUtil.isBlank(props.getAppSecret())) {
throw new CheckedException("下游伙伴配置不完整(base-url / app-key / app-secret): " + partnerCode);
}
}
@Override
public JSONObject hotelSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body) {
return post(partnerCode, props, hotelId, extHotelId, "hotel_sync", DownstreamHotelApi.HOTEL_SYNC, body);
}
@Override
public JSONObject apartmentSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body) {
return post(partnerCode, props, hotelId, extHotelId, "apartment_sync", DownstreamHotelApi.APARTMENT_SYNC, body);
}
@Override
public JSONObject productSync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body) {
return post(partnerCode, props, hotelId, extHotelId, "product_sync", DownstreamHotelApi.PRODUCT_SYNC, body);
}
@Override
public JSONObject dailySync(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
Object body) {
return post(partnerCode, props, hotelId, extHotelId, "daily_sync", DownstreamHotelApi.DAILY_SYNC, body);
}
@Override
public JSONObject orderQuery(String partnerCode, HotelDownstreamProperties props, Object body) {
return post(partnerCode, props, null, null, "order_query", DownstreamHotelApi.ORDER_QUERY, body);
}
@Override
public JSONObject orderDetail(String partnerCode, HotelDownstreamProperties props, Object body) {
return post(partnerCode, props, null, null, "order_detail", DownstreamHotelApi.ORDER_DETAIL, body);
}
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);
int maxRetry = Math.max(0, props.getRateLimitMaxRetries());
long backoff = Math.max(1000L, props.getRateLimitBackoffMs());
CheckedException last = null;
for (int attempt = 0; attempt <= maxRetry; attempt++) {
acquireRatePermit(props, path);
try {
return doPostOnce(partnerCode, props, hotelId, extHotelId, action, path, bodyJson);
}
catch (CheckedException e) {
last = e;
if (!isRateLimitedMsg(e.getMessage()) || attempt >= maxRetry) {
throw e;
}
long wait = backoff * (1L << attempt);
log.warn("|{}|限流退避|partner={}|path={}|attempt={}|waitMs={}|", TAG, partnerCode, path, attempt + 1,
wait);
sleepQuiet(wait);
}
}
throw last != null ? last : new CheckedException("下游调用失败");
}
private JSONObject doPostOnce(String partnerCode, HotelDownstreamProperties props, Long hotelId, String extHotelId,
String action, String path, String bodyJson) {
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = DownstreamSignHelper.sign(props.getAppKey(), props.getAppSecret(), timestamp, nonce, bodyJson);
String url = trimSlash(props.getBaseUrl()) + path;
log.info("|{}|req|partner={}|path={}|body={}|", TAG, partnerCode, path, bodyJson);
long start = System.currentTimeMillis();
Integer httpStatus = null;
String res = null;
Integer bizCode = null;
boolean ok = false;
String errMsg = null;
try (HttpResponse response = HttpRequest.post(url)
.timeout(props.getTimeoutMs())
.header("Content-Type", "application/json;charset=UTF-8")
.header("appKey", props.getAppKey())
.header("timestamp", timestamp)
.header("nonce", nonce)
.header("sign", sign)
.body(bodyJson)
.execute()) {
httpStatus = response.getStatus();
res = response.body();
log.info("|{}|res|partner={}|path={}|http={}|body={}|", TAG, partnerCode, path, httpStatus, res);
if (StrUtil.isBlank(res)) {
errMsg = "下游响应为空: " + path;
throw new CheckedException(errMsg);
}
String trimmed = res.trim();
if (httpStatus != null && httpStatus >= 400) {
errMsg = StrUtil.format("下游 HTTP {} [{}] body={}", httpStatus, path, StrUtil.maxLength(trimmed, 180));
throw new CheckedException(errMsg);
}
if (trimmed.startsWith("<")) {
errMsg = StrUtil.format("下游返回 HTML 而非 JSON(多为 404/网关错误)[{}] http={} body={}", path,
httpStatus, StrUtil.maxLength(trimmed, 120));
throw new CheckedException(errMsg);
}
JSONObject json;
try {
json = JSONUtil.parseObj(res);
}
catch (Exception parseEx) {
errMsg = StrUtil.format("下游响应非 JSON [{}] http={} err={} body={}", path, httpStatus,
parseEx.getMessage(), StrUtil.maxLength(trimmed, 120));
throw new CheckedException(errMsg);
}
bizCode = json.getInt("code");
if (bizCode == null || bizCode != 200) {
errMsg = StrUtil.format("下游失败[{}] code={} msg={}", path, bizCode, json.getStr("msg"));
throw new CheckedException(errMsg);
}
ok = true;
return json;
}
catch (CheckedException e) {
if (errMsg == null) {
errMsg = e.getMessage();
}
throw e;
}
catch (Exception e) {
errMsg = "下游调用失败: " + e.getMessage();
throw new CheckedException(errMsg);
}
finally {
writeLog(props, partnerCode, hotelId, extHotelId, action, url, bodyJson, res, httpStatus, bizCode, ok,
errMsg, System.currentTimeMillis() - start);
}
}
/** 按 appKey + 接口组做分钟窗限流(多实例共享 Redis) */
private void acquireRatePermit(HotelDownstreamProperties props, String path) {
int limit = resolveRateLimit(props, path);
if (limit <= 0 || StrUtil.isBlank(props.getAppKey())) {
return;
}
String group = rateGroup(path);
for (int i = 0; i < 60; i++) {
long minute = System.currentTimeMillis() / 60_000L;
String key = RATE_KEY + props.getAppKey() + ":" + group + ":" + minute;
Long n = redisTemplate.opsForValue().increment(key);
if (n != null && n == 1L) {
redisTemplate.expire(key, 2, TimeUnit.MINUTES);
}
if (n == null || n <= limit) {
return;
}
long wait = 60_000L - (System.currentTimeMillis() % 60_000L) + 20L;
sleepQuiet(Math.min(wait, 5000L));
}
log.warn("|{}|限流等待超时仍继续|appKey={}|path={}|limit={}|", TAG, props.getAppKey(), path, limit);
}
private static int resolveRateLimit(HotelDownstreamProperties props, String path) {
String g = rateGroup(path);
if ("hotel_apt".equals(g)) {
return props.getRateLimitHotelApartmentPerMin();
}
if ("product_daily".equals(g)) {
return props.getRateLimitProductDailyPerMin();
}
if ("order_query".equals(g)) {
return props.getRateLimitOrderQueryPerMin();
}
if ("order_detail".equals(g)) {
return props.getRateLimitOrderDetailPerMin();
}
return 300;
}
private static String rateGroup(String path) {
if (path == null) {
return "other";
}
if (path.contains("/hotel/sync") || path.contains("/apartment/sync")) {
return "hotel_apt";
}
if (path.contains("/product/sync") || path.contains("/daily/sync")) {
return "product_daily";
}
if (path.contains("/order/query")) {
return "order_query";
}
if (path.contains("/order/detail")) {
return "order_detail";
}
return "other";
}
private static boolean isRateLimitedMsg(String msg) {
return StrUtil.isNotBlank(msg) && msg.contains("访问过于频繁");
}
private static void sleepQuiet(long ms) {
if (ms <= 0) {
return;
}
try {
Thread.sleep(ms);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void writeLog(HotelDownstreamProperties props, String partnerCode, Long hotelId, String extHotelId,
String action, String url, String requestBody, String responseBody, Integer httpStatus, Integer bizCode,
boolean ok, String errMsg, long costMs) {
if (!shouldPersistLog(props, ok)) {
return;
}
int maxChars = props == null ? 2048 : 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.setRequestBody(truncateBody(requestBody, maxChars));
row.setResponseBody(truncateBody(responseBody, maxChars));
row.setHttpStatus(httpStatus);
row.setBizCode(bizCode);
row.setSuccess(ok ? "1" : "0");
row.setErrorMsg(StrUtil.maxLength(errMsg, 900));
row.setCostMs(costMs);
pushLogService.record(row);
}
/**
* fail_only:只记失败;sample:失败全记 + 上下文内成功抽样;all:全记。
*/
private static boolean shouldPersistLog(HotelDownstreamProperties props, boolean ok) {
String mode = props == null ? "fail_only" : StrUtil.blankToDefault(props.getPushLogMode(), "fail_only")
.trim()
.toLowerCase();
if ("all".equals(mode)) {
return true;
}
if (!ok) {
return true;
}
if ("sample".equals(mode)) {
return DownstreamPushLogContext.tryConsumeSuccessSample();
}
// fail_only 及未知值
return false;
}
private static String truncateBody(String body, int maxChars) {
if (body == null) {
return null;
}
if (maxChars <= 0 || body.length() <= maxChars) {
return body;
}
return body.substring(0, maxChars) + "...(truncated,len=" + body.length() + ")";
}
private static String trimSlash(String base) {
if (base == null) {
return "";
}
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
}
}
@@ -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.integration.hotel.upstream;
import com.alibaba.fastjson.JSONObject;
/**
* 酒店上游订单网关(按数据来源渠道插拔)。
* <p>
* 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可。
*/
public interface HotelUpstreamOrderGateway {
/** 渠道标识,与 hotel.sheet_name / 字典值一致,如 shangyou */
String channel();
/** 可定校验,返回上游 content(渠道自定义结构) */
JSONObject checkAvailability(JSONObject body);
/** 创建上游订单 */
HotelUpstreamOrderResult createOrder(JSONObject body);
/** 按下游 requestNo / channelNo 查询 */
HotelUpstreamOrderResult queryByRequestNo(String requestNo);
/** 按上游平台单号查询 */
HotelUpstreamOrderResult queryOrder(String platformNo, String hotelCode);
/** 取消上游订单 */
HotelUpstreamOrderResult cancelOrder(String platformNo, String hotelCode);
}
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 按酒店数据来源(sheet_name)解析上游订单网关。
* <ul>
* <li>local:无上游网关</li>
* <li>空白历史:兼容映射到商游</li>
* <li>已注册渠道:返回对应 Gateway</li>
* <li>未注册渠道:{@link #isUnsupportedChannel} 为 true,履约应硬失败</li>
* </ul>
*/
@Component
public class HotelUpstreamOrderGatewayRegistry {
private final Map<String, HotelUpstreamOrderGateway> byChannel = new HashMap<>();
public HotelUpstreamOrderGatewayRegistry(List<HotelUpstreamOrderGateway> gateways) {
if (gateways != null) {
for (HotelUpstreamOrderGateway g : gateways) {
if (g == null || StrUtil.isBlank(g.channel())) {
continue;
}
byChannel.put(SmHotelDataSource.normalize(g.channel()).toLowerCase(), g);
}
}
}
/** 落库/展示用的渠道键:local / shangyou / 其它 sheet_name */
public String resolveChannelKey(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return SmHotelDataSource.LOCAL;
}
String ds = SmHotelDataSource.normalize(hotelSheetName);
if (ds.isEmpty()) {
return SmHotelDataSource.SHANGYOU;
}
return ds.toLowerCase();
}
/**
* 查找网关。本地返回 empty;空白历史按商游;未注册渠道返回 empty(配合 {@link #isUnsupportedChannel})。
*/
public Optional<HotelUpstreamOrderGateway> find(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return Optional.empty();
}
String key = resolveChannelKey(hotelSheetName);
return Optional.ofNullable(byChannel.get(key.toLowerCase()));
}
/** 非本地且未注册实现的渠道 */
public boolean isUnsupportedChannel(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return false;
}
String key = resolveChannelKey(hotelSheetName);
return !byChannel.containsKey(key.toLowerCase());
}
}
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
import lombok.Data;
/**
* 上游订单调用的统一结果(渠道无关语义,供履约/补偿/流水复用)。
*/
@Data
public class HotelUpstreamOrderResult {
/** HTTP/网络超时、5xx 等,未拿到确定业务结果 */
private boolean uncertain;
/** 业务码,如 200 / 3028 */
private String code;
private String message;
/** content 对象(orderId/bookStatus 等) */
private JSONObject content;
private JSONObject raw;
private Integer httpStatus;
private String exceptionMsg;
public boolean isBizOk() {
return !uncertain && ("200".equals(code) || "0".equals(code) || "OK".equalsIgnoreCase(code));
}
/** 总对总付款失败,文档要求走补偿 */
public boolean isPayFail3028() {
return "3028".equals(code);
}
public String orderId() {
return content == null ? null : content.getString("orderId");
}
public String bookStatus() {
return content == null ? null : content.getString("bookStatus");
}
public String payStatus() {
return content == null ? null : content.getString("payStatus");
}
public static HotelUpstreamOrderResult uncertain(String msg, Integer httpStatus) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(true);
r.setMessage(msg);
r.setHttpStatus(httpStatus);
r.setExceptionMsg(msg);
return r;
}
public static HotelUpstreamOrderResult fail(String code, String message) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode(code);
r.setMessage(message);
return r;
}
/** 兼容现有商游客户端结果 */
public static HotelUpstreamOrderResult fromShangyou(ShangyouOrderCallResult src) {
if (src == null) {
return uncertain("上游无响应", null);
}
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(src.isUncertain());
r.setCode(src.getCode());
r.setMessage(src.getMessage());
r.setContent(src.getContent());
r.setRaw(src.getRaw());
r.setHttpStatus(src.getHttpStatus());
r.setExceptionMsg(src.getExceptionMsg());
return r;
}
public static HotelUpstreamOrderResult ofCancel(boolean ok, String message) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode(ok ? "200" : "FAIL");
r.setMessage(StrUtil.blankToDefault(message, ok ? "ok" : "cancel failed"));
JSONObject content = new JSONObject();
content.put("cancelled", ok);
r.setContent(content);
return r;
}
}
@@ -0,0 +1,64 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 商游(玉编电云)上游订单网关。
*/
@Component
@RequiredArgsConstructor
public class ShangyouHotelUpstreamGateway implements HotelUpstreamOrderGateway {
private final ShangyouHotelClient client;
@Override
public String channel() {
return SmHotelDataSource.SHANGYOU;
}
@Override
public JSONObject checkAvailability(JSONObject body) {
return client.checkAvailability(body);
}
@Override
public HotelUpstreamOrderResult createOrder(JSONObject body) {
return HotelUpstreamOrderResult.fromShangyou(client.createOrderRaw(body));
}
@Override
public HotelUpstreamOrderResult queryByRequestNo(String requestNo) {
return HotelUpstreamOrderResult.fromShangyou(client.queryByRequestNoRaw(requestNo));
}
@Override
public HotelUpstreamOrderResult queryOrder(String platformNo, String hotelCode) {
try {
JSONObject content = client.queryOrder(platformNo, hotelCode);
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode("200");
r.setContent(content);
r.setRaw(content);
return r;
}
catch (Exception e) {
return HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
}
@Override
public HotelUpstreamOrderResult cancelOrder(String platformNo, String hotelCode) {
try {
boolean ok = client.cancelOrder(platformNo, hotelCode);
return HotelUpstreamOrderResult.ofCancel(ok, ok ? "取消成功" : "上游取消返回失败");
}
catch (Exception e) {
return HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
}
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.integration.shangyou;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -18,14 +19,15 @@ import java.util.concurrent.atomic.AtomicBoolean;
/**
* 消费 Redis 回调队列:BRPOP 高优队列优先于普通队列。
* <p>
* 另起看门狗线程:即使 Worker 卡在某次上游调用里,也能把超时 RUNNING 重置入队,避免整队假死。
* 线程始终拉起;是否真正消费看渠道运行时 {@code callbackQueueEnabled}(库表可覆盖 YAML)。
* 看门狗定期把滞留 PENDING / 超时 RUNNING 重新入队,避免「库里全是待处理、Redis 已空」假死。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouCallbackQueueWorker implements ApplicationRunner {
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final StringRedisTemplate redisTemplate;
@@ -33,21 +35,27 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
private final AtomicBoolean running = new AtomicBoolean(true);
/** 上次消费循环是否处于启用态;从关→开时补一次 PENDING 入队 */
private final AtomicBoolean lastEnabled = new AtomicBoolean(false);
private Thread worker;
private Thread watchdog;
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
@Override
public void run(ApplicationArguments args) {
if (!properties.isCallbackQueueEnabled()) {
log.info("|回调队列|Worker 未启用(shangyou-hotel.callback-queue-enabled=false)");
return;
boolean enabled = props().isCallbackQueueEnabled();
lastEnabled.set(enabled);
if (!enabled) {
log.warn("|回调队列|当前 callback-queue-enabled=false,Worker 已启动但暂停消费;"
+ "库表/YAML 打开后自动恢复。PENDING 任务不会执行,直到开启。");
}
try {
callbackTaskService.requeueStalePending();
}
catch (Exception e) {
log.warn("|回调队列|恢复 PENDING 失败(可稍后人工重试)", e);
else {
safeRequeuePending();
}
worker = new Thread(this::loop, "shangyou-callback-queue");
worker.setDaemon(true);
@@ -57,7 +65,7 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
watchdog.setDaemon(true);
watchdog.start();
log.info("|回调队列|Worker+Watchdog 已启动|high={}|normal={}|",
log.info("|回调队列|Worker+Watchdog 已启动|enabled={}|high={}|normal={}|", enabled,
ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH, ShangyouHotelApi.REDIS_CALLBACK_QUEUE);
}
@@ -65,6 +73,19 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
int consecutiveFail = 0;
while (running.get()) {
try {
ShangyouHotelProperties p = props();
boolean enabled = p.isCallbackQueueEnabled();
if (!enabled) {
if (lastEnabled.getAndSet(false)) {
log.warn("|回调队列|已暂停消费(callback-queue-enabled=false)");
}
Thread.sleep(3000L);
continue;
}
if (!lastEnabled.getAndSet(true)) {
log.info("|回调队列|已恢复消费,补入队滞留 PENDING");
safeRequeuePending();
}
// 底层 BRPOP 多 key:先高优再普通(ListOperations 无多 key 重载)
String taskId = brpopPreferHigh(5);
if (taskId == null || taskId.isBlank() || "null".equalsIgnoreCase(taskId)) {
@@ -79,7 +100,7 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
continue;
}
boolean ok = callbackTaskService.processQueuedTask(id);
int interval = Math.max(0, properties.getCallbackTaskIntervalMs());
int interval = Math.max(0, p.getCallbackTaskIntervalMs());
if (interval > 0) {
Thread.sleep(interval);
}
@@ -88,9 +109,9 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
}
else {
consecutiveFail++;
int pauseAfter = Math.max(1, properties.getCallbackFailPauseAfter());
int pauseAfter = Math.max(1, p.getCallbackFailPauseAfter());
if (consecutiveFail >= pauseAfter) {
int pauseMs = Math.max(0, properties.getCallbackFailPauseMs());
int pauseMs = Math.max(0, p.getCallbackFailPauseMs());
log.warn("|回调队列|连续失败 {} 次,冷却 {}ms 后再继续|", consecutiveFail, pauseMs);
if (pauseMs > 0) {
Thread.sleep(pauseMs);
@@ -119,6 +140,15 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
}
}
private void safeRequeuePending() {
try {
callbackTaskService.requeueStalePending();
}
catch (Exception e) {
log.warn("|回调队列|恢复 PENDING 失败(可稍后人工重试)", e);
}
}
/**
* Redis BRPOP:按 key 顺序优先弹出高优队列。
* 当前 Spring Data Redis 的 {@code ListOperations#rightPop} 不支持多 key,走 connection 原生命令。
@@ -141,7 +171,7 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
return taskId == null ? null : taskId.trim();
}
/** 不依赖 Worker 空闲:定时扫卡死的 RUNNING */
/** 不依赖 Worker 空闲:扫超时 RUNNING + 滞留 PENDING(Redis 丢队列时也能救回来) */
private void watchdogLoop() {
while (running.get()) {
try {
@@ -149,7 +179,12 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
if (!running.get()) {
break;
}
if (!props().isCallbackQueueEnabled()) {
continue;
}
callbackTaskService.recoverStaleRunning();
// 只补「超过约 3 分钟仍 PENDING」的孤儿任务,避免把排队中的任务反复 leftPush
callbackTaskService.requeueOrphanPending();
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -30,28 +31,35 @@ import org.springframework.data.redis.core.ZSetOperations;
/**
* 玉编电云 CRS 客户端:Basic 换 Token,业务请求 Bearer + cardId/cardPassWord。
* <p>
* 运行时参数优先读库表渠道对接配置(热更新),未配置时回落 YAML/Nacos。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouHotelClient {
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final StringRedisTemplate redisTemplate;
/** 库表覆盖后的生效配置,保存后台对接配置后立即生效 */
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
public boolean isEnabled() {
return properties.isEnabled();
return props().isEnabled();
}
public void ensureEnabled() {
if (!properties.isEnabled()) {
throw new CheckedException("上游酒店未启用,请配置 shangyou-hotel.enabled=true");
if (!props().isEnabled()) {
throw new CheckedException("上游酒店未启用:请在「渠道对接配置」启用商游,或配置 shangyou-hotel.enabled=true");
}
if (StrUtil.hasBlank(properties.resolveAuthBaseUrl(), properties.resolveBizBaseUrl(), properties.getClientId(),
properties.getClientSecret(), properties.getCardId(), properties.getCardPassword())) {
if (StrUtil.hasBlank(props().resolveAuthBaseUrl(), props().resolveBizBaseUrl(), props().getClientId(),
props().getClientSecret(), props().getCardId(), props().getCardPassword())) {
throw new CheckedException(
"上游酒店配置不完整:至少配置 base-url(或 auth-base-url+biz-base-url)以及 clientId/secret、cardId/password");
"上游酒店配置不完整:请在「渠道对接配置」填写 baseUrl/clientId/secret/cardId/password,或配置 YAML 默认值");
}
}
@@ -105,7 +113,7 @@ public class ShangyouHotelClient {
if (StrUtil.isNotBlank(reused)) {
return reused;
}
long lockTtlSec = Math.max(45L, properties.getTimeoutMs() / 1000L + 15L);
long lockTtlSec = Math.max(45L, props().getTimeoutMs() / 1000L + 15L);
for (int attempt = 0; attempt < 4; attempt++) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", lockTtlSec, TimeUnit.SECONDS);
@@ -217,9 +225,9 @@ public class ShangyouHotelClient {
}
private String fetchAndCacheToken() {
String url = properties.resolveAuthBaseUrl() + ShangyouHotelApi.OAUTH_TOKEN;
String url = props().resolveAuthBaseUrl() + ShangyouHotelApi.OAUTH_TOKEN;
String basic = Base64.getEncoder()
.encodeToString((properties.getClientId() + ":" + properties.getClientSecret())
.encodeToString((props().getClientId() + ":" + props().getClientSecret())
.getBytes(StandardCharsets.UTF_8));
log.info("shangyou oauth token url={}", url);
try (HttpResponse response = HttpRequest.post(url)
@@ -228,7 +236,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=utf-8")
.header("Connection", "Keep-Alive")
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String body = response.body();
if (!response.isOk()) {
@@ -238,14 +246,14 @@ public class ShangyouHotelClient {
String token = firstNonBlank(resp.getString("access_token"), resp.getString("accessToken"));
if (StrUtil.isBlank(token)) {
throw new CheckedException("上游鉴权未返回 access_token: " + body + "(请核对 auth 地址是否为 "
+ properties.resolveAuthBaseUrl() + "/oauth/token )");
+ props().resolveAuthBaseUrl() + "/oauth/token )");
}
long expires = resp.getLongValue("expires_in");
if (expires <= 0) {
expires = 21600L;
}
// 文档建议缓存约 5 小时 58 分:expires_in(21600) - skew(默认120秒)
long ttl = Math.max(60L, expires - properties.getTokenSkewSeconds());
long ttl = Math.max(60L, expires - props().getTokenSkewSeconds());
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
redisTemplate.opsForValue()
.set(ShangyouHotelApi.REDIS_TOKEN_REFRESHED_AT, String.valueOf(System.currentTimeMillis()), ttl,
@@ -400,9 +408,9 @@ public class ShangyouHotelClient {
JSONArray arr = asArray(resp.get("content"));
resp.remove("content");
int count = arr.size();
if (count >= properties.getHotelListWarnSize()) {
if (count >= props().getHotelListWarnSize()) {
log.warn("|上游酒店列表|数量较大|count={}|warnSize={}|同步期会有短时内存峰值,建议低峰强制刷新|", count,
properties.getHotelListWarnSize());
props().getHotelListWarnSize());
}
String tmpHash = ShangyouHotelApi.REDIS_HOTEL_HASH_KEY + ":tmp";
String tmpZset = ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY + ":tmp";
@@ -446,7 +454,7 @@ public class ShangyouHotelClient {
redisTemplate.rename(tmpHash, ShangyouHotelApi.REDIS_HOTEL_HASH_KEY);
redisTemplate.rename(tmpZset, ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
redisTemplate.rename(tmpSet, ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
long ttl = properties.getHotelListCacheMinutes();
long ttl = props().getHotelListCacheMinutes();
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, ttl, TimeUnit.MINUTES);
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY, ttl, TimeUnit.MINUTES);
// ID 集合给差集用,TTL 略长,避免日更空窗
@@ -458,7 +466,7 @@ public class ShangyouHotelClient {
redisTemplate.delete(tmpSet);
}
log.info("|上游酒店列表|同步完成|count={}|costMs={}|cacheMinutes={}|store=ZSET+HASH+SET|", count,
System.currentTimeMillis() - t0, properties.getHotelListCacheMinutes());
System.currentTimeMillis() - t0, props().getHotelListCacheMinutes());
}
catch (Exception e) {
redisTemplate.delete(tmpHash);
@@ -529,7 +537,7 @@ public class ShangyouHotelClient {
if (!levels.isEmpty()) {
body.put("memberLevelList", levels);
}
String path = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
String path = StrUtil.blankToDefault(props().getRoomPricePath(), "/hotel/getRoomPrice");
if (!path.startsWith("/")) {
path = "/" + path;
}
@@ -541,7 +549,7 @@ public class ShangyouHotelClient {
* 对方约定约 20 QPS;配置 price-api-qps≤0 关闭。
*/
private void acquirePriceApiPermit() {
double qps = properties.getPriceApiQps();
double qps = props().getPriceApiQps();
if (qps <= 0) {
return;
}
@@ -581,7 +589,7 @@ public class ShangyouHotelClient {
if (StrUtil.isBlank(path)) {
return false;
}
String configured = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
String configured = StrUtil.blankToDefault(props().getRoomPricePath(), "/hotel/getRoomPrice");
String p = path.startsWith("/") ? path : "/" + path;
String c = configured.startsWith("/") ? configured : "/" + configured;
return p.equalsIgnoreCase(c) || p.toLowerCase().contains("getroomprice");
@@ -613,7 +621,7 @@ public class ShangyouHotelClient {
public ShangyouOrderCallResult createOrderRaw(JSONObject body) {
fillCard(body);
if (StrUtil.isBlank(body.getString("bookerId"))) {
body.put("bookerId", StrUtil.blankToDefault(properties.getBookerId(), properties.getCardId()));
body.put("bookerId", StrUtil.blankToDefault(props().getBookerId(), props().getCardId()));
}
return postOrderBiz(ShangyouHotelApi.ORDER_ADD, body);
}
@@ -689,7 +697,7 @@ public class ShangyouHotelClient {
catch (Exception e) {
return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null);
}
String url = properties.resolveBizBaseUrl() + path;
String url = props().resolveBizBaseUrl() + path;
log.info("shangyou order request path={} retried={} bodyKeys={}", path, retried, body.keySet());
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
@@ -697,7 +705,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String respBody = response.body();
int status = response.getStatus();
@@ -746,7 +754,7 @@ public class ShangyouHotelClient {
}
public String resolveCallbackSecret() {
return StrUtil.blankToDefault(properties.getCallbackSecret(), properties.getCardPassword());
return StrUtil.blankToDefault(props().getCallbackSecret(), props().getCardPassword());
}
private JSONObject baseCardBody() {
@@ -756,15 +764,15 @@ public class ShangyouHotelClient {
}
private void fillCard(JSONObject body) {
body.put("cardId", properties.getCardId());
body.put("cardPassWord", properties.getCardPassword());
body.put("cardId", props().getCardId());
body.put("cardPassWord", props().getCardPassword());
}
private List<String> parseMemberLevels() {
if (StrUtil.isBlank(properties.getMemberLevels())) {
if (StrUtil.isBlank(props().getMemberLevels())) {
return Collections.emptyList();
}
return StrUtil.splitTrim(properties.getMemberLevels(), ',');
return StrUtil.splitTrim(props().getMemberLevels(), ',');
}
private JSONObject postBiz(String path, JSONObject body) {
@@ -785,7 +793,7 @@ public class ShangyouHotelClient {
acquirePriceApiPermit();
}
String token = getAccessToken(forceToken, invalidToken);
String url = properties.resolveBizBaseUrl() + path;
String url = props().resolveBizBaseUrl() + path;
log.info("shangyou request path={} retryRound={} forceToken={} bodyKeys={}", path, retryRound, forceToken,
body.keySet());
try (HttpResponse response = HttpRequest.post(url)
@@ -794,7 +802,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String respBody = response.body();
boolean httpUnauthorized = response.getStatus() == 401;
@@ -1,6 +1,8 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import jakarta.annotation.PreDestroy;
@@ -14,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
/**
* 订单补偿调度:按文档在下单后第 3/5/10 分钟 queryByRequestNo(必要时重试 order/add)。
* 启用状态读库表热配置,后台开关后无需重启 Worker。
*/
@Slf4j
@Component
@@ -22,6 +25,8 @@ public class ShangyouOrderCompensateWorker implements ApplicationRunner {
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelOrderService orderService;
private final AtomicBoolean running = new AtomicBoolean(true);
@@ -30,19 +35,19 @@ public class ShangyouOrderCompensateWorker implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
if (!properties.isEnabled()) {
log.info("|订单补偿|上游未启用,Worker 不启动");
return;
}
worker = new Thread(this::loop, "shangyou-order-compensate");
worker.setDaemon(true);
worker.start();
log.info("|订单补偿|Worker 已启动(轮询 next_compensate_time)");
log.info("|订单补偿|Worker 已启动(轮询 next_compensate_time;渠道启用热感知)");
}
private void loop() {
while (running.get()) {
try {
if (!channelConfigService.isChannelEnabled(SmHotelDataSource.SHANGYOU)) {
Thread.sleep(30000L);
continue;
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
int n = TenantBroker.applyAs(tenantId, tid -> orderService.processDueCompensate(20));
if (n > 0) {
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 下游推送流水按保留天数清理(默认每天 04:40)。
* 天数取各启用伙伴 YAML 的最大值;都未配则用 hotel-downstream 默认。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelDownstreamPushLogCleanupSchedule {
private final HotelDownstreamProperties yamlDefaults;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelDownstreamPushLogService pushLogService;
@Scheduled(cron = "${hotel-downstream.push-log-cleanup-cron:0 40 4 * * ?}")
public void cleanup() {
Long tenantId = 1L;
try {
TenantBroker.runAs(tenantId, tid -> {
int retainDays = resolveRetainDays();
if (retainDays <= 0) {
log.debug("|下游推送流水|清理跳过|retainDays={}|", retainDays);
return;
}
int n = pushLogService.cleanupOlderThanDays(retainDays);
if (n > 0) {
log.info("|下游推送流水|清理完成|retainDays={}|rows={}|", retainDays, n);
}
});
}
catch (Exception e) {
log.warn("|下游推送流水|清理异常|{}", e.getMessage());
}
}
private int resolveRetainDays() {
// 进程默认 ≤0:关闭自动清理
if (yamlDefaults != null && yamlDefaults.getPushLogRetainDays() <= 0) {
return 0;
}
int max = yamlDefaults == null ? 15 : yamlDefaults.getPushLogRetainDays();
for (String code : channelConfigService.listEnabledDownstreamPartners()) {
HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code);
if (p != null && p.getPushLogRetainDays() > max) {
max = p.getPushLogRetainDays();
}
}
return max;
}
}
@@ -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);
}
}
}
@@ -0,0 +1,52 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelHuanqiOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 订单增量定时拉取:进程级 cron 到点后,只拉各伙伴 YAML 中 order-pull-join-schedule=true 的下游。
* 默认伙伴不进定时(仅手动);cron 读 hotel-downstream.order-pull-cron。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelHuanqiOrderSchedule {
private final HotelDownstreamProperties yamlDefaults;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelHuanqiOrderService huanqiOrderService;
@Scheduled(cron = "${hotel-downstream.order-pull-cron:0 */5 * * * ?}")
public void scheduledPull() {
HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot();
if (def == null) {
def = yamlDefaults;
}
if (def == null || !def.isOrderPullScheduleEnabled()) {
return;
}
Long tenantId = 1L;
try {
TenantBroker.runAs(tenantId, tid -> {
var r = huanqiOrderService.pullScheduledPartners();
if (r != null && r.getCode() == 0) {
log.info("|环启订单拉取|定时完成|{}", r.getData());
}
else {
log.warn("|环启订单拉取|定时失败|{}", r == null ? "null" : r.getMsg());
}
});
}
catch (Exception e) {
log.error("|环启订单拉取|定时异常", e);
}
}
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import lombok.RequiredArgsConstructor;
@@ -10,24 +11,31 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* YAML 可配 Cron 的日更入口(与 XXL Job 二选一即可)。
* YAML / 库表可配 Cron 的日更入口(与 XXL Job 二选一即可)。
* <p>
* 开关:{@code shangyou-hotel.spring-schedule-enabled}<br>
* 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron} / {@code local-daily-roll-cron}
* Cron 表达式本身仍读 YAML(改触发时刻需重启);是否执行读库表热配置
* {@code springScheduleEnabled} / 各项日更开关,测试环境关闭即可,无需重启。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelShangyouSpringSchedule {
private final ShangyouHotelProperties properties;
private final ShangyouHotelProperties yamlProperties;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelSyncService syncService;
private final SmHotelOrderService hotelOrderService;
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
@Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}")
public void scheduledListSync() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -45,6 +53,7 @@ public class SmHotelShangyouSpringSchedule {
@Scheduled(cron = "${shangyou-hotel.price-roll-daily-cron:0 0 3 * * ?}")
public void scheduledPriceRoll() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -62,6 +71,7 @@ public class SmHotelShangyouSpringSchedule {
@Scheduled(cron = "${shangyou-hotel.local-daily-roll-cron:0 30 3 * * ?}")
public void scheduledLocalDailyRoll() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -80,12 +90,14 @@ public class SmHotelShangyouSpringSchedule {
/**
* App 待支付超时清理:默认每分钟扫一次,取消过期单并释放本地预占库存。
* 不依赖 spring-schedule-enabled(支付超时与日更无关)。
* 租户与订单补偿 Worker 一致,走 callback-tenant-id。
*/
@Scheduled(fixedDelayString = "${shangyou-hotel.app-pay-expire-scan-ms:60000}")
public void scheduledCancelExpiredAppUnpaid() {
try {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
ShangyouHotelProperties properties = props();
Long tenantId = properties.getCallbackTenantId() == null
? (yamlProperties.getCallbackTenantId() == null ? 1L : yamlProperties.getCallbackTenantId())
: properties.getCallbackTenantId();
TenantBroker.runAs(tenantId, tid -> hotelOrderService.cancelExpiredAppUnpaidOrders(100));
}
catch (Exception e) {
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelChannelConfigMapper extends PjcloudBaseMapper<SmHotelChannelConfigEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelChannelDeptMapper extends PjcloudBaseMapper<SmHotelChannelDeptEntity> {
}
@@ -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);
/**
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelDownstreamPushLogMapper extends PjcloudBaseMapper<SmHotelDownstreamPushLogEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelOrderUpstreamLogMapper extends PjcloudBaseMapper<SmHotelOrderUpstreamLogEntity> {
}
@@ -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);
@@ -41,9 +53,15 @@ public interface SmHotelCallbackTaskService {
/** 消费一条队列任务(Worker 调用);返回 true=本次成功/跳过,false=失败或待再试 */
boolean processQueuedTask(Long taskId);
/** 启动时把滞留 PENDING / 超时 RUNNING 重新入队 */
/** 启动/人工:把全部 PENDING + 超时 RUNNING 重新入队 */
int requeueStalePending();
/**
* 看门狗:只把「超过若干分钟仍为 PENDING」的任务补入队(避免 Redis 丢队列后假死;
* 不会把刚入队、尚在排队的任务反复 leftPush)。
*/
int requeueOrphanPending();
/** 仅把超时仍为 RUNNING 的任务重置入队(Worker 空闲时定期调用) */
int recoverStaleRunning();
}
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import java.util.List;
public interface SmHotelChannelConfigService extends IService<SmHotelChannelConfigEntity> {
Page<SmHotelChannelConfigEntity> pageConfigs(Page<SmHotelChannelConfigEntity> page, String channel,
String direction);
/** 默认查上游 */
SmHotelChannelConfigEntity getByChannel(String channel);
SmHotelChannelConfigEntity getByChannel(String channel, String direction);
R<Boolean> saveOrUpdateByChannel(SmHotelChannelConfigEntity entity);
R<Boolean> removeByIdsSafe(Long[] ids);
/** 清缓存,下次调用重新读库 */
void evictCache(String channel);
/**
* 商游运行时配置:库表覆盖 YAML 默认;无库表行则纯 YAML。
* 热更新:保存后 {@link #evictCache},无需重启。
*/
ShangyouHotelProperties resolveShangyouRuntime();
/** 下游伙伴运行时配置(库表覆盖 YAML 默认) */
HotelDownstreamProperties resolveDownstreamRuntime(String partnerCode);
/** 已启用的下游伙伴编码列表 */
List<String> listEnabledDownstreamPartners();
/** 参与资源日更定时推送的启用伙伴(resource-push-join-schedule=true) */
List<String> listResourcePushSchedulePartners();
/** 渠道是否在库表中启用(无行时回落 YAML enabled;默认按上游) */
boolean isChannelEnabled(String channel);
boolean isChannelEnabled(String channel, String direction);
/** YAML/Nacos 默认快照,供后台表单对照 */
ShangyouHotelProperties yamlDefaultsSnapshot();
HotelDownstreamProperties downstreamYamlDefaultsSnapshot();
/** 已实现的下游协议列表(后台下拉) */
java.util.List<String> listDownstreamProtocols();
/** 已配置的下游伙伴(范围映射下拉,编码只在对接配置维护一次) */
java.util.List<SmHotelChannelConfigEntity> listDownstreamPartners();
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import java.util.List;
public interface SmHotelChannelDeptService extends IService<SmHotelChannelDeptEntity> {
Page<SmHotelChannelDeptEntity> pageMappings(Page<SmHotelChannelDeptEntity> page, SmHotelChannelDeptEntity query);
List<SmHotelChannelDeptEntity> listEnabled();
R<Boolean> saveMapping(SmHotelChannelDeptEntity entity);
R<Boolean> updateMapping(SmHotelChannelDeptEntity entity);
R<Boolean> removeMappings(Long[] ids);
/** 上游渠道 → 归属部门(仅 direction=upstream) */
Long resolveDeptId(String channel);
R<Long> requireEnabledDeptId(String channel);
/**
* 下游:按酒店匹配应推送的伙伴。
* <ul>
* <li>scope=dept:部门 + 可选区域</li>
* <li>scope=hotel:hotelIds 包含该酒店</li>
* </ul>
*/
List<String> listDownstreamPartnersForHotel(Long hotelId, Long deptId, Long districtAdcode, String district);
/**
* 下游:汇总某伙伴全部启用范围映射下的上架酒店 ID(去重)。
* 用于「按下游范围全量推送」。
*/
List<Long> listHotelIdsForDownstreamPartner(String partnerCode);
}
@@ -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,24 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
public interface SmHotelDownstreamPushLogService extends IService<SmHotelDownstreamPushLogEntity> {
Page<SmHotelDownstreamPushLogEntity> pageLogs(Page<SmHotelDownstreamPushLogEntity> page,
SmHotelDownstreamPushLogEntity query);
/** 异步落库推送流水(失败不影响主流程) */
void record(SmHotelDownstreamPushLogEntity logEntity);
/** 清理早于 retainDays 的流水(逻辑删除);返回影响行数 */
int cleanupOlderThanDays(int retainDays);
/** 删除单条推送流水 */
R<Boolean> removeLog(Long id);
/** 按创建时间区间删除(至少指定一端) */
R<Integer> removeByCreateTimeRange(String createTimeBegin, String createTimeEnd);
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.supermarket.service;
import cn.hutool.json.JSONObject;
import com.bsd.pjcloud.common.core.util.R;
import java.util.Map;
/**
* 环启订单:B→A 推送接收 + A→B 增量拉取,统一按 orderNo/changeTime 幂等落库。
*/
public interface SmHotelHuanqiOrderService {
/**
* 应用一条环启订单报文(推送或拉取)。
* @return applied / skipped / reason 等
*/
R<Map<String, Object>> applyPayload(String partnerCode, JSONObject payload);
/** 对单个启用伙伴做一轮增量拉取(含翻页) */
R<Map<String, Object>> pullIncremental(String partnerCode);
/**
* 手动:对所有启用且 orderPullEnabled 的伙伴各拉一轮(不论是否 join-schedule)。
*/
R<Map<String, Object>> pullAllEnabledPartners();
/**
* 定时:仅拉取 orderPullEnabled 且 orderPullJoinSchedule 的伙伴。
*/
R<Map<String, Object>> pullScheduledPartners();
/** 按 appKey 反查伙伴编码(推送验签后用) */
String resolvePartnerByAppKey(String appKey);
}
@@ -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);
}
@@ -40,12 +40,25 @@ public interface SmHotelMidService {
R<Map<String, Object>> pullPriceRange(Long hotelId, java.time.LocalDate startInclusive,
java.time.LocalDate endExclusive);
/** 将本地酒店+房型+产品+日历推送给下游销售方 */
R<Map<String, Object>> pushToDownstream(Long hotelId);
/**
* 将本地酒店+房型+产品+日历推送给下游销售方。
* 内部分类:可售推 / 关房推 / 纯跳过(见 SmHotelDownstreamPushKind)。
* @param partnerCode 指定下游伙伴编码;必填,仅推该伙伴(且须在范围映射内)
*/
R<Map<String, Object>> pushToDownstream(Long hotelId, String partnerCode);
R<Map<String, Object>> pullAndPush(Long hotelId);
/**
* 对指定产品推下游窗口内关房日历(删产品前调用,避免删后无法组报文)。
*/
R<Map<String, Object>> closeProductDailyOnDownstream(Long hotelId, String partnerCode, String productCode,
String roomTypeCode);
R<Map<String, Object>> pullAndPushBatch(List<Long> hotelIds);
R<Map<String, Object>> pullAndPush(Long hotelId, String partnerCode);
R<Map<String, Object>> pullAndPushBatch(List<Long> hotelIds, String partnerCode);
/** 已启用下游伙伴下拉(推送前选择) */
R<List<Map<String, Object>>> listEnabledDownstreamPartnerOptions();
/** 酒店详情:主数据 + 已拉取的房型/产品/日历预览 */
R<SmHotelDetailVO> getHotelDetail(Long hotelId);
@@ -4,11 +4,13 @@ import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
import com.bsd.pjcloud.common.core.util.R;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public interface SmHotelOrderService {
@@ -96,4 +98,19 @@ public interface SmHotelOrderService {
* 预订单状态变更回调落库(验签后由上层调用)。
*/
Map<String, Object> handleOrderStatusNotify(Map<String, Object> body);
/** 上游调用流水(按时间升序) */
List<SmHotelOrderUpstreamLogEntity> listUpstreamLogs(Long orderId);
/**
* 对已落库订单尝试上游创建(环启推送/拉取后异步调用)。
* 已有 platformNo 则幂等跳过。
*/
R<Boolean> bookUpstreamForExisting(Long orderId);
/**
* 下游销售前二次校验(查本地酒店/产品/上架/日历库存/可选验价)。
* 入参建议:extHotelId、extProductId、checkIn、checkOut、roomCnt、price(可选)。
*/
R<Map<String, Object>> checkBookableForDownstream(Map<String, Object> body);
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.supermarket.service;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import java.util.List;
public interface SmHotelOrderUpstreamLogService {
void saveCall(Long orderId, String channel, String action, HotelUpstreamOrderResult result, String reqJson,
long costMs);
void saveSimple(Long orderId, String channel, String action, boolean success, String bizCode, String message,
String reqJson, String respJson, Long costMs);
List<SmHotelOrderUpstreamLogEntity> listByOrderId(Long orderId);
}
@@ -40,8 +40,15 @@ public interface SmHotelSyncService {
/**
* 异步拉取全部启用酒店详情(房型+产品+未来 price-days 房价)。
* @param limit 最多处理家数,null/≤0 用配置 detail-pull-max-hotels(0=不限)
* @param dataSource 第三方渠道(须已在「渠道部门」维护);只拉取该渠道酒店并归属对应部门
*/
R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType);
R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType, String dataSource);
/**
* 按下游范围映射异步全量推送(只推本地已落库数据,不再拉上游)。
* @param partnerCode 下游伙伴编码(对接配置中已启用)
*/
R<SmHotelSyncLogEntity> startDownstreamScopePushAsync(String partnerCode, String triggerType);
/**
* 根据某次同步日志的 failDetail,只重试失败酒店(支持 DETAIL_PULL / PRICE_ROLL)。
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.SmHotelCallbackTaskMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelCallbackTaskService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.core.util.R;
@@ -48,7 +49,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
public static final String STATUS_SKIPPED = "SKIPPED";
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final ShangyouHotelClient client;
@@ -62,6 +63,11 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
private final StringRedisTemplate redisTemplate;
/** 与 Worker 一致:读渠道库表覆盖后的运行时,不要只读 YAML 默认值 */
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
@Override
public Map<String, Object> enqueueFromNotify(String notifyType, Map<String, Object> body, String time,
String sign) {
@@ -75,18 +81,29 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
return failResp("401", "验签失败");
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
ShangyouHotelProperties p = props();
Long tenantId = p.getCallbackTenantId() == null ? 1L : p.getCallbackTenantId();
try {
TenantBroker.runAs(tenantId, tid -> {
SmHotelCallbackTaskEntity task = buildTask(notifyType, body, time, tid);
// 房价/房量:同酒店已有待处理任务则并入日期区间,不再新建、不再入队
if (mergeIntoPending(task)) {
return;
}
taskMapper.insert(task);
if (properties.isCallbackQueueEnabled()) {
if (p.isCallbackQueueEnabled()) {
enqueueTaskId(task.getId(), task.getNotifyType());
}
log.info("|上游酒店通知|已入队|taskId={}|type={}|priority={}|hotelCode={}|hotelId={}|status={}|",
else {
task.setResultMsg("已落库,但 callback-queue-enabled=false,未入 Redis;开启后会自动补入队");
taskMapper.updateById(task);
log.warn("|上游酒店通知|未入 Redis(队列关闭)|taskId={}|type={}|", task.getId(),
task.getNotifyType());
}
log.info("|上游酒店通知|已入队|taskId={}|type={}|priority={}|hotelCode={}|hotelId={}|status={}|queueOn={}|",
task.getId(), task.getNotifyType(),
isHighPriority(task.getNotifyType()) ? "HIGH" : "NORMAL", task.getHotelCode(),
task.getHotelId(), task.getStatus());
task.getHotelId(), task.getStatus(), p.isCallbackQueueEnabled());
});
}
catch (Exception e) {
@@ -122,12 +139,106 @@ 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("任务不存在");
}
if (STATUS_RUNNING.equals(task.getStatus()) || STATUS_PENDING.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);
@@ -137,13 +248,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
@@ -230,29 +343,47 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
if (taskId == null) {
return true;
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
ShangyouHotelProperties p = props();
Long tenantId = p.getCallbackTenantId() == null ? 1L : p.getCallbackTenantId();
Boolean ok = TenantBroker.applyAs(tenantId, tid -> doProcess(taskId));
return Boolean.TRUE.equals(ok);
}
@Override
public int requeueStalePending() {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
return requeuePendingInternal(null);
}
@Override
public int requeueOrphanPending() {
// 超过 3 分钟仍 PENDING,视为 Redis 未消费到(丢队列 / 当时未入队)
return requeuePendingInternal(LocalDateTime.now().minusMinutes(3));
}
/**
* @param olderThan 非空时仅入队 updateTime &lt; olderThan 的 PENDING;空则入队全部 PENDING(最多 200)
*/
private int requeuePendingInternal(LocalDateTime olderThan) {
ShangyouHotelProperties p = props();
if (!p.isCallbackQueueEnabled()) {
return 0;
}
Long tenantId = p.getCallbackTenantId() == null ? 1L : p.getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
int n = 0;
// 1) PENDING:按创建时间 FIFO;ORDER_STATUS 进高优队列
var pending = taskMapper.selectList(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
var qw = Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_PENDING)
.lt(olderThan != null, SmHotelCallbackTaskEntity::getUpdateTime, olderThan)
.orderByAsc(SmHotelCallbackTaskEntity::getCreateTime)
.last("limit 200"));
.last("limit 200");
var pending = taskMapper.selectList(qw);
for (SmHotelCallbackTaskEntity t : pending) {
enqueueTaskId(t.getId(), t.getNotifyType());
n++;
}
// 2) 卡住的 RUNNING
n += resetStaleRunningLocked();
if (n > 0) {
log.info("|回调队列|重启恢复入队|pendingAndStaleRunning={}|", n);
log.info("|回调队列|补入队|pendingFilterOlderThan={}|count={}|", olderThan, n);
}
return n;
});
@@ -260,7 +391,8 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
@Override
public int recoverStaleRunning() {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
ShangyouHotelProperties p = props();
Long tenantId = p.getCallbackTenantId() == null ? 1L : p.getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> resetStaleRunningLocked());
}
@@ -268,7 +400,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
private int resetStaleRunningLocked() {
// 单日区间拉取正常应秒级完成;2 分钟仍 RUNNING 视为卡死(Token 冷却/进程挂起等)
LocalDateTime staleBefore = LocalDateTime.now().minusMinutes(2);
int max = Math.max(1, properties.getCallbackMaxAttempts());
int max = Math.max(1, props().getCallbackMaxAttempts());
var running = taskMapper.selectList(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_RUNNING)
.and(w -> w.lt(SmHotelCallbackTaskEntity::getStartProcessTime, staleBefore)
@@ -313,7 +445,7 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
return true;
}
int attempts = task.getAttemptCount() == null ? 0 : task.getAttemptCount();
int max = Math.max(1, properties.getCallbackMaxAttempts());
int max = Math.max(1, props().getCallbackMaxAttempts());
task.setStatus(STATUS_RUNNING);
task.setAttemptCount(attempts + 1);
task.setStartProcessTime(LocalDateTime.now());
@@ -342,6 +474,12 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
finish(task, STATUS_SKIPPED, "无商游可写酒店(hotelCode=" + task.getHotelCode() + "),本地或其它渠道不会覆盖", t0);
return true;
}
// 消费前把同酒店其余待处理房价/房量并进本条,积压的重复通知不再各拉一次
int folded = foldPendingSiblings(task);
if (folded > 0) {
log.info("|回调队列|合并同酒店待处理|taskId={}|hotelCode={}|folded={}|{}~{}|", task.getId(),
task.getHotelCode(), folded, task.getStartDate(), task.getEndDate());
}
// 已绑定 hotelId 时仍校验来源:禁止回调覆盖本地/其它渠道
SmHotelEntity bound = hotelMapper.selectById(task.getHotelId());
if (bound == null || !SmHotelDataSource.isWritableByChannel(bound.getSheetName(), SmHotelDataSource.SHANGYOU)) {
@@ -398,9 +536,9 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
start = endInclusive;
endInclusive = tmp;
}
// 防异常超长区间(回调一般就几天)
if (endInclusive.isAfter(start.plusDays(31))) {
endInclusive = start.plusDays(31);
// 合并后区间可能更长;拉取内部按 30 天切片,这里只防异常超长
if (endInclusive.isAfter(start.plusDays(92))) {
endInclusive = start.plusDays(92);
}
LocalDate endExclusive = endInclusive.plusDays(1);
log.info("|回调队列|按区间拉取|taskId={}|hotelId={}|{}~{}|", task.getId(), task.getHotelId(), start,
@@ -482,6 +620,117 @@ public class SmHotelCallbackTaskServiceImpl implements SmHotelCallbackTaskServic
task.getResultMsg());
}
/** 房价、房量按酒店合并;订单状态逐条处理 */
private static boolean isCoalesceType(String notifyType) {
return "PRICE".equalsIgnoreCase(notifyType) || "INVENTORY".equalsIgnoreCase(notifyType);
}
/**
* 同酒店、同类型已有 PENDING 时,把本条日期并进那条,不再插新任务。
* @return true 已合并
*/
private boolean mergeIntoPending(SmHotelCallbackTaskEntity incoming) {
if (incoming == null || !isCoalesceType(incoming.getNotifyType()) || StrUtil.isBlank(incoming.getHotelCode())) {
return false;
}
SmHotelCallbackTaskEntity pending = taskMapper.selectOne(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_PENDING)
.eq(SmHotelCallbackTaskEntity::getNotifyType, incoming.getNotifyType())
.eq(SmHotelCallbackTaskEntity::getHotelCode, incoming.getHotelCode())
.orderByAsc(SmHotelCallbackTaskEntity::getId)
.last("limit 1"));
if (pending == null) {
return false;
}
String[] range = unionDateRange(pending.getStartDate(), pending.getEndDate(), incoming.getStartDate(),
incoming.getEndDate());
pending.setStartDate(range[0]);
pending.setEndDate(range[1]);
pending.setModifyTime(StrUtil.blankToDefault(incoming.getModifyTime(), pending.getModifyTime()));
pending.setReqTime(incoming.getReqTime());
pending.setReqJson(incoming.getReqJson());
pending.setResultMsg("已合并后续" + ("INVENTORY".equalsIgnoreCase(incoming.getNotifyType()) ? "房量" : "房价")
+ "通知,区间 " + StrUtil.blankToDefault(range[0], "-") + "~" + StrUtil.blankToDefault(range[1], "-"));
pending.setUpdateTime(LocalDateTime.now());
taskMapper.updateById(pending);
log.info("|上游酒店通知|并入待处理|keepTaskId={}|type={}|hotelCode={}|{}~{}|", pending.getId(),
pending.getNotifyType(), pending.getHotelCode(), range[0], range[1]);
return true;
}
/**
* 本条开始拉取前,把同酒店其余 PENDING 标为跳过,日期取并集写回本条。
* 队列里那些 id 被弹出时已非 PENDING,会直接跳过。
*/
private int foldPendingSiblings(SmHotelCallbackTaskEntity task) {
if (task == null || task.getId() == null || !isCoalesceType(task.getNotifyType())
|| StrUtil.isBlank(task.getHotelCode())) {
return 0;
}
int folded = 0;
String start = task.getStartDate();
String end = task.getEndDate();
while (folded < 5000) {
List<SmHotelCallbackTaskEntity> batch = taskMapper.selectList(Wrappers.<SmHotelCallbackTaskEntity>lambdaQuery()
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_PENDING)
.eq(SmHotelCallbackTaskEntity::getNotifyType, task.getNotifyType())
.eq(SmHotelCallbackTaskEntity::getHotelCode, task.getHotelCode())
.ne(SmHotelCallbackTaskEntity::getId, task.getId())
.orderByAsc(SmHotelCallbackTaskEntity::getId)
.last("limit 200"));
if (batch == null || batch.isEmpty()) {
break;
}
List<Long> ids = new java.util.ArrayList<>(batch.size());
for (SmHotelCallbackTaskEntity other : batch) {
String[] range = unionDateRange(start, end, other.getStartDate(), other.getEndDate());
start = range[0];
end = range[1];
ids.add(other.getId());
}
taskMapper.update(null, Wrappers.<SmHotelCallbackTaskEntity>lambdaUpdate()
.set(SmHotelCallbackTaskEntity::getStatus, STATUS_SKIPPED)
.set(SmHotelCallbackTaskEntity::getResultMsg, "已合并到任务 " + task.getId() + ",不再单独拉取")
.set(SmHotelCallbackTaskEntity::getFinishTime, LocalDateTime.now())
.set(SmHotelCallbackTaskEntity::getUpdateTime, LocalDateTime.now())
.in(SmHotelCallbackTaskEntity::getId, ids)
.eq(SmHotelCallbackTaskEntity::getStatus, STATUS_PENDING));
folded += ids.size();
if (batch.size() < 200) {
break;
}
}
if (folded > 0) {
task.setStartDate(start);
task.setEndDate(end);
taskMapper.update(null, Wrappers.<SmHotelCallbackTaskEntity>lambdaUpdate()
.set(SmHotelCallbackTaskEntity::getStartDate, start)
.set(SmHotelCallbackTaskEntity::getEndDate, end)
.set(SmHotelCallbackTaskEntity::getUpdateTime, LocalDateTime.now())
.eq(SmHotelCallbackTaskEntity::getId, task.getId()));
}
return folded;
}
/** 四段日期取最早开始、最晚结束;都空则保持空(拉取时再按今天起 7 天) */
private String[] unionDateRange(String startA, String endA, String startB, String endB) {
LocalDate min = null;
LocalDate max = null;
for (String raw : new String[] { startA, endA, startB, endB }) {
LocalDate d = parseDate(raw);
if (d == null) {
continue;
}
if (min == null || d.isBefore(min)) {
min = d;
}
if (max == null || d.isAfter(max)) {
max = d;
}
}
return new String[] { min == null ? null : min.toString(), max == null ? null : max.toString() };
}
private SmHotelCallbackTaskEntity buildTask(String notifyType, Map<String, Object> body, String time,
Long tenantId) {
String hotelCode = str(body, "hotelId");
@@ -0,0 +1,548 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelChannelDirection;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
import com.bsd.pjcloud.supermarket.dto.SmHotelDownstreamChannelConfigDTO;
import com.bsd.pjcloud.supermarket.dto.SmHotelShangyouChannelConfigDTO;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelChannelConfigMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.util.ChannelConfigTextParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 渠道对接参数:库表覆盖 YAML,进程内缓存,保存后立即失效无需重启。
* direction=upstream 上游;direction=downstream 下游销售方(可多家)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelConfigMapper, SmHotelChannelConfigEntity>
implements SmHotelChannelConfigService {
private final ShangyouHotelProperties yamlDefaults;
private final HotelDownstreamProperties downstreamYamlDefaults;
/** channel → 合并后的商游运行时配置(仅 shangyou 上游) */
private final Map<String, ShangyouHotelProperties> shangyouRuntimeCache = new ConcurrentHashMap<>();
/** partnerCode → 下游运行时 */
private final Map<String, HotelDownstreamProperties> downstreamRuntimeCache = new ConcurrentHashMap<>();
@Override
public Page<SmHotelChannelConfigEntity> pageConfigs(Page<SmHotelChannelConfigEntity> page, String channel,
String direction) {
String dir = StrUtil.isBlank(direction) ? null : SmHotelChannelDirection.normalize(direction);
return baseMapper.selectPage(page, Wrappers.<SmHotelChannelConfigEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(channel), SmHotelChannelConfigEntity::getChannel,
normalizeChannelCode(channel))
.eq(dir != null, SmHotelChannelConfigEntity::getDirection, dir)
.orderByDesc(SmHotelChannelConfigEntity::getUpdateTime)
.orderByDesc(SmHotelChannelConfigEntity::getCreateTime));
}
@Override
public SmHotelChannelConfigEntity getByChannel(String channel) {
return getByChannel(channel, SmHotelChannelDirection.UPSTREAM);
}
@Override
public SmHotelChannelConfigEntity getByChannel(String channel, String direction) {
String ch = normalizeChannelCode(channel);
if (ch.isEmpty()) {
return null;
}
String dir = SmHotelChannelDirection.normalize(direction);
return baseMapper.selectOne(Wrappers.<SmHotelChannelConfigEntity>lambdaQuery()
.eq(SmHotelChannelConfigEntity::getChannel, ch)
.eq(SmHotelChannelConfigEntity::getDirection, dir)
.last("limit 1"));
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveOrUpdateByChannel(SmHotelChannelConfigEntity entity) {
if (entity == null) {
return R.failed("参数不能为空");
}
String dir = SmHotelChannelDirection.normalize(entity.getDirection());
entity.setDirection(dir);
String ch = normalizeChannelCode(entity.getChannel());
if (ch.isEmpty()) {
return R.failed(SmHotelChannelDirection.isDownstream(dir) ? "请填写下游伙伴编码" : "请选择渠道");
}
if (SmHotelChannelDirection.isUpstream(dir) && SmHotelDataSource.isLocal(ch)) {
return R.failed("本地渠道无需对接配置");
}
entity.setChannel(ch);
if (SmHotelChannelDirection.isDownstream(dir)) {
String protocol = SmHotelDownstreamProtocol.blankToDefault(entity.getProtocol());
if (!SmHotelDownstreamProtocol.isSupported(protocol)) {
return R.failed("不支持的下游协议「" + entity.getProtocol() + "」,当前已实现:"
+ String.join(",", SmHotelDownstreamProtocol.supported()));
}
entity.setProtocol(protocol);
}
else {
// 上游:协议可空或与渠道同名,不强校验
if (StrUtil.isBlank(entity.getProtocol())) {
entity.setProtocol(ch);
}
}
if (StrUtil.isBlank(entity.getEnabled())) {
entity.setEnabled("0");
}
if (StrUtil.isNotBlank(entity.getConfigJson())) {
R<String> textCheck = validateConfigText(entity.getConfigJson());
if (textCheck != null) {
return R.failed(textCheck.getMsg());
}
}
SmHotelChannelConfigEntity existing = getByChannel(ch, dir);
boolean ok;
if (existing != null) {
entity.setId(existing.getId());
ok = baseMapper.updateById(entity) > 0;
}
else {
ok = baseMapper.insert(entity) > 0;
}
if (ok) {
evictCache(ch);
log.info("|渠道对接配置|已保存并刷新缓存|direction={}|channel={}|enabled={}|", dir, ch, entity.getEnabled());
}
return R.ok(ok);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeByIdsSafe(Long[] ids) {
if (ids == null || ids.length == 0) {
return R.failed("请选择要删除的记录");
}
for (Long id : ids) {
SmHotelChannelConfigEntity row = baseMapper.selectById(id);
if (row != null) {
evictCache(row.getChannel());
}
}
return R.ok(removeByIds(Arrays.asList(ids)));
}
@Override
public void evictCache(String channel) {
String ch = normalizeChannelCode(channel);
if (ch.isEmpty()) {
shangyouRuntimeCache.clear();
downstreamRuntimeCache.clear();
return;
}
shangyouRuntimeCache.remove(ch);
downstreamRuntimeCache.remove(ch);
}
@Override
public ShangyouHotelProperties resolveShangyouRuntime() {
return shangyouRuntimeCache.computeIfAbsent(SmHotelDataSource.SHANGYOU, this::buildShangyouRuntime);
}
@Override
public HotelDownstreamProperties resolveDownstreamRuntime(String partnerCode) {
String code = normalizeChannelCode(partnerCode);
if (code.isEmpty()) {
return copyDownstreamProps(downstreamYamlDefaults);
}
return downstreamRuntimeCache.computeIfAbsent(code, this::buildDownstreamRuntime);
}
@Override
public List<String> listEnabledDownstreamPartners() {
List<SmHotelChannelConfigEntity> rows = baseMapper.selectList(Wrappers.<SmHotelChannelConfigEntity>lambdaQuery()
.eq(SmHotelChannelConfigEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelConfigEntity::getEnabled, "1")
.orderByAsc(SmHotelChannelConfigEntity::getChannel));
List<String> codes = rows.stream().map(SmHotelChannelConfigEntity::getChannel).filter(StrUtil::isNotBlank)
.map(this::normalizeChannelCode).distinct().collect(Collectors.toList());
// 库表无启用下游时,回落旧 YAML(单下游兼容)
if (codes.isEmpty() && downstreamYamlDefaults.isEnabled()) {
codes.add("default");
}
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);
}
@Override
public boolean isChannelEnabled(String channel, String direction) {
String ch = normalizeChannelCode(channel);
String dir = SmHotelChannelDirection.normalize(direction);
if (ch.isEmpty()) {
return false;
}
SmHotelChannelConfigEntity row = getByChannel(ch, dir);
if (row != null) {
return "1".equals(row.getEnabled());
}
if (SmHotelChannelDirection.isUpstream(dir) && SmHotelDataSource.SHANGYOU.equalsIgnoreCase(ch)) {
return yamlDefaults.isEnabled();
}
if (SmHotelChannelDirection.isDownstream(dir) && "default".equals(ch)) {
return downstreamYamlDefaults.isEnabled();
}
return false;
}
@Override
public ShangyouHotelProperties yamlDefaultsSnapshot() {
return copyProps(yamlDefaults);
}
@Override
public HotelDownstreamProperties downstreamYamlDefaultsSnapshot() {
return copyDownstreamProps(downstreamYamlDefaults);
}
@Override
public List<String> listDownstreamProtocols() {
return SmHotelDownstreamProtocol.supported().stream().toList();
}
@Override
public List<SmHotelChannelConfigEntity> listDownstreamPartners() {
return baseMapper.selectList(Wrappers.<SmHotelChannelConfigEntity>lambdaQuery()
.eq(SmHotelChannelConfigEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.orderByAsc(SmHotelChannelConfigEntity::getChannel));
}
private ShangyouHotelProperties buildShangyouRuntime(String channel) {
ShangyouHotelProperties merged = copyProps(yamlDefaults);
SmHotelChannelConfigEntity row = getByChannel(channel, SmHotelChannelDirection.UPSTREAM);
if (row == null) {
return merged;
}
merged.setEnabled("1".equals(row.getEnabled()));
if (StrUtil.isBlank(row.getConfigJson())) {
return merged;
}
try {
SmHotelShangyouChannelConfigDTO dto = ChannelConfigTextParser.parseToShangyouDto(row.getConfigJson());
if (dto != null) {
overlayShangyou(merged, dto);
}
}
catch (Exception e) {
log.warn("|渠道对接配置|解析上游配置失败,回落 YAML|channel={}|err={}|", channel, e.getMessage());
}
return merged;
}
private HotelDownstreamProperties buildDownstreamRuntime(String partnerCode) {
HotelDownstreamProperties merged = copyDownstreamProps(downstreamYamlDefaults);
merged.setProtocol(SmHotelDownstreamProtocol.blankToDefault(merged.getProtocol()));
if ("default".equals(partnerCode)) {
return merged;
}
SmHotelChannelConfigEntity row = getByChannel(partnerCode, SmHotelChannelDirection.DOWNSTREAM);
if (row == null) {
merged.setEnabled(false);
return merged;
}
merged.setEnabled("1".equals(row.getEnabled()));
// 库表 protocol 优先;YAML 里的 protocol 可补充覆盖
if (StrUtil.isNotBlank(row.getProtocol())) {
merged.setProtocol(SmHotelDownstreamProtocol.normalize(row.getProtocol()));
}
if (StrUtil.isBlank(row.getConfigJson())) {
return merged;
}
try {
SmHotelDownstreamChannelConfigDTO dto = ChannelConfigTextParser.parseToDownstreamDto(row.getConfigJson());
if (dto != null) {
overlayDownstream(merged, dto);
}
}
catch (Exception e) {
log.warn("|渠道对接配置|解析下游配置失败,回落 YAML|partner={}|err={}|", partnerCode, e.getMessage());
}
merged.setProtocol(SmHotelDownstreamProtocol.blankToDefault(merged.getProtocol()));
return merged;
}
private void overlayDownstream(HotelDownstreamProperties target, SmHotelDownstreamChannelConfigDTO dto) {
if (StrUtil.isNotBlank(dto.getProtocol())) {
target.setProtocol(SmHotelDownstreamProtocol.normalize(dto.getProtocol()));
}
if (StrUtil.isNotBlank(dto.getBaseUrl())) {
target.setBaseUrl(dto.getBaseUrl().trim());
}
if (StrUtil.isNotBlank(dto.getAppKey())) {
target.setAppKey(dto.getAppKey().trim());
}
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());
}
if (dto.getDailyDays() != null && dto.getDailyDays() > 0) {
target.setDailyDays(dto.getDailyDays());
}
if (dto.getRateLimitHotelApartmentPerMin() != null) {
target.setRateLimitHotelApartmentPerMin(dto.getRateLimitHotelApartmentPerMin());
}
if (dto.getRateLimitProductDailyPerMin() != null) {
target.setRateLimitProductDailyPerMin(dto.getRateLimitProductDailyPerMin());
}
if (dto.getRateLimitOrderQueryPerMin() != null) {
target.setRateLimitOrderQueryPerMin(dto.getRateLimitOrderQueryPerMin());
}
if (dto.getRateLimitOrderDetailPerMin() != null) {
target.setRateLimitOrderDetailPerMin(dto.getRateLimitOrderDetailPerMin());
}
if (dto.getRateLimitMaxRetries() != null && dto.getRateLimitMaxRetries() >= 0) {
target.setRateLimitMaxRetries(dto.getRateLimitMaxRetries());
}
if (dto.getRateLimitBackoffMs() != null && dto.getRateLimitBackoffMs() > 0) {
target.setRateLimitBackoffMs(dto.getRateLimitBackoffMs());
}
if (dto.getOrderPullEnabled() != null) {
target.setOrderPullEnabled(dto.getOrderPullEnabled());
}
if (dto.getOrderPullJoinSchedule() != null) {
target.setOrderPullJoinSchedule(dto.getOrderPullJoinSchedule());
}
if (dto.getOrderPullOverlapMinutes() != null && dto.getOrderPullOverlapMinutes() >= 0) {
target.setOrderPullOverlapMinutes(dto.getOrderPullOverlapMinutes());
}
if (dto.getOrderPullPageSize() != null && dto.getOrderPullPageSize() > 0) {
target.setOrderPullPageSize(dto.getOrderPullPageSize());
}
if (StrUtil.isNotBlank(dto.getPushLogMode())) {
target.setPushLogMode(dto.getPushLogMode().trim().toLowerCase());
}
if (dto.getPushLogSampleSuccessMax() != null && dto.getPushLogSampleSuccessMax() >= 0) {
target.setPushLogSampleSuccessMax(dto.getPushLogSampleSuccessMax());
}
if (dto.getPushLogBodyMaxChars() != null) {
target.setPushLogBodyMaxChars(dto.getPushLogBodyMaxChars());
}
if (dto.getPushLogRetainDays() != null) {
target.setPushLogRetainDays(dto.getPushLogRetainDays());
}
if (dto.getResourcePushJoinSchedule() != null) {
target.setResourcePushJoinSchedule(dto.getResourcePushJoinSchedule());
}
}
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
if (StrUtil.isNotBlank(dto.getBaseUrl())) {
target.setBaseUrl(dto.getBaseUrl().trim());
}
if (dto.getAuthBaseUrl() != null) {
target.setAuthBaseUrl(StrUtil.trimToEmpty(dto.getAuthBaseUrl()));
}
if (dto.getBizBaseUrl() != null) {
target.setBizBaseUrl(StrUtil.trimToEmpty(dto.getBizBaseUrl()));
}
if (StrUtil.isNotBlank(dto.getClientId())) {
target.setClientId(dto.getClientId().trim());
}
if (StrUtil.isNotBlank(dto.getClientSecret())) {
target.setClientSecret(dto.getClientSecret().trim());
}
if (StrUtil.isNotBlank(dto.getCardId())) {
target.setCardId(dto.getCardId().trim());
}
if (StrUtil.isNotBlank(dto.getCardPassword())) {
target.setCardPassword(dto.getCardPassword().trim());
}
if (dto.getCallbackSecret() != null) {
target.setCallbackSecret(StrUtil.trimToEmpty(dto.getCallbackSecret()));
}
if (StrUtil.isNotBlank(dto.getBookerId())) {
target.setBookerId(dto.getBookerId().trim());
}
if (dto.getOrderIsGroup() != null) {
target.setOrderIsGroup(dto.getOrderIsGroup());
}
if (StrUtil.isNotBlank(dto.getOrderGuaranteeMode())) {
target.setOrderGuaranteeMode(dto.getOrderGuaranteeMode().trim());
}
if (StrUtil.isNotBlank(dto.getRoomPricePath())) {
target.setRoomPricePath(dto.getRoomPricePath().trim());
}
if (dto.getMemberLevels() != null) {
target.setMemberLevels(StrUtil.trimToEmpty(dto.getMemberLevels()));
}
if (dto.getTimeoutMs() != null && dto.getTimeoutMs() > 0) {
target.setTimeoutMs(dto.getTimeoutMs());
}
if (dto.getTokenSkewSeconds() != null && dto.getTokenSkewSeconds() >= 0) {
target.setTokenSkewSeconds(dto.getTokenSkewSeconds());
}
if (dto.getPriceDays() != null && dto.getPriceDays() > 0) {
target.setPriceDays(dto.getPriceDays());
}
if (dto.getHotelListCacheMinutes() != null && dto.getHotelListCacheMinutes() > 0) {
target.setHotelListCacheMinutes(dto.getHotelListCacheMinutes());
}
if (dto.getHotelListWarnSize() != null && dto.getHotelListWarnSize() > 0) {
target.setHotelListWarnSize(dto.getHotelListWarnSize());
}
if (dto.getCallbackTenantId() != null) {
target.setCallbackTenantId(dto.getCallbackTenantId());
}
if (dto.getCallbackQueueEnabled() != null) {
target.setCallbackQueueEnabled(dto.getCallbackQueueEnabled());
}
if (dto.getCallbackMaxAttempts() != null && dto.getCallbackMaxAttempts() > 0) {
target.setCallbackMaxAttempts(dto.getCallbackMaxAttempts());
}
if (dto.getCallbackTaskIntervalMs() != null && dto.getCallbackTaskIntervalMs() >= 0) {
target.setCallbackTaskIntervalMs(dto.getCallbackTaskIntervalMs());
}
if (dto.getPriceApiQps() != null) {
target.setPriceApiQps(dto.getPriceApiQps());
}
if (dto.getAppPayTimeoutMinutes() != null && dto.getAppPayTimeoutMinutes() > 0) {
target.setAppPayTimeoutMinutes(dto.getAppPayTimeoutMinutes());
}
if (dto.getSpringScheduleEnabled() != null) {
target.setSpringScheduleEnabled(dto.getSpringScheduleEnabled());
}
if (dto.getHotelListDailySyncEnabled() != null) {
target.setHotelListDailySyncEnabled(dto.getHotelListDailySyncEnabled());
}
if (StrUtil.isNotBlank(dto.getHotelListDailyCron())) {
target.setHotelListDailyCron(dto.getHotelListDailyCron().trim());
}
if (dto.getPriceRollDailyEnabled() != null) {
target.setPriceRollDailyEnabled(dto.getPriceRollDailyEnabled());
}
if (StrUtil.isNotBlank(dto.getPriceRollDailyCron())) {
target.setPriceRollDailyCron(dto.getPriceRollDailyCron().trim());
}
if (dto.getLocalDailyRollEnabled() != null) {
target.setLocalDailyRollEnabled(dto.getLocalDailyRollEnabled());
}
if (StrUtil.isNotBlank(dto.getLocalDailyRollCron())) {
target.setLocalDailyRollCron(dto.getLocalDailyRollCron().trim());
}
if (dto.getAutoImportNewHotels() != null) {
target.setAutoImportNewHotels(dto.getAutoImportNewHotels());
}
if (dto.getAutoImportBatchSize() != null && dto.getAutoImportBatchSize() > 0) {
target.setAutoImportBatchSize(dto.getAutoImportBatchSize());
}
if (dto.getPriceRollDayOffset() != null && dto.getPriceRollDayOffset() > 0) {
target.setPriceRollDayOffset(dto.getPriceRollDayOffset());
}
if (dto.getPriceRollBatchSize() != null && dto.getPriceRollBatchSize() > 0) {
target.setPriceRollBatchSize(dto.getPriceRollBatchSize());
}
if (dto.getPriceRollIntervalMs() != null && dto.getPriceRollIntervalMs() >= 0) {
target.setPriceRollIntervalMs(dto.getPriceRollIntervalMs());
}
if (dto.getFullPriceSyncMaxPerDay() != null && dto.getFullPriceSyncMaxPerDay() > 0) {
target.setFullPriceSyncMaxPerDay(dto.getFullPriceSyncMaxPerDay());
}
if (dto.getFullPriceSyncMinIntervalHours() != null && dto.getFullPriceSyncMinIntervalHours() >= 0) {
target.setFullPriceSyncMinIntervalHours(dto.getFullPriceSyncMinIntervalHours());
}
if (dto.getDetailPullIntervalMs() != null && dto.getDetailPullIntervalMs() >= 0) {
target.setDetailPullIntervalMs(dto.getDetailPullIntervalMs());
}
if (dto.getDetailPullMaxHotels() != null && dto.getDetailPullMaxHotels() >= 0) {
target.setDetailPullMaxHotels(dto.getDetailPullMaxHotels());
}
if (dto.getDiffScanBatchSize() != null && dto.getDiffScanBatchSize() > 0) {
target.setDiffScanBatchSize(dto.getDiffScanBatchSize());
}
if (dto.getAutoMarkOfflineHotels() != null) {
target.setAutoMarkOfflineHotels(dto.getAutoMarkOfflineHotels());
}
if (dto.getAutoReactivateOnlineHotels() != null) {
target.setAutoReactivateOnlineHotels(dto.getAutoReactivateOnlineHotels());
}
if (StrUtil.isNotBlank(dto.getDefaultImportDataSource())) {
target.setDefaultImportDataSource(dto.getDefaultImportDataSource().trim());
}
}
private static ShangyouHotelProperties copyProps(ShangyouHotelProperties src) {
ShangyouHotelProperties copy = new ShangyouHotelProperties();
BeanUtil.copyProperties(src, copy);
return copy;
}
private static HotelDownstreamProperties copyDownstreamProps(HotelDownstreamProperties src) {
HotelDownstreamProperties copy = new HotelDownstreamProperties();
BeanUtil.copyProperties(src, copy);
return copy;
}
private String normalizeChannelCode(String channel) {
return SmHotelDataSource.normalize(channel).toLowerCase();
}
private R<String> validateConfigText(String text) {
try {
ChannelConfigTextParser.validate(text);
return null;
}
catch (Exception e) {
return R.failed("对接参数格式错误(支持 YAML/# 注释 或 JSON):" + e.getMessage());
}
}
}
@@ -0,0 +1,439 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.constant.SmHotelChannelDirection;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamScopeType;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelChannelDeptMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDeptMapper, SmHotelChannelDeptEntity>
implements SmHotelChannelDeptService {
private final RemoteDeptService remoteDeptService;
private final SmHotelMapper hotelMapper;
@Override
public Page<SmHotelChannelDeptEntity> pageMappings(Page<SmHotelChannelDeptEntity> page,
SmHotelChannelDeptEntity query) {
String dir = StrUtil.isBlank(query.getDirection()) ? null : SmHotelChannelDirection.normalize(query.getDirection());
Page<SmHotelChannelDeptEntity> result = baseMapper.selectPage(page, Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(query.getChannel()), SmHotelChannelDeptEntity::getChannel,
normalizeCode(query.getChannel()))
.eq(dir != null, SmHotelChannelDeptEntity::getDirection, dir)
.eq(StrUtil.isNotBlank(query.getScopeType()), SmHotelChannelDeptEntity::getScopeType,
SmHotelDownstreamScopeType.normalize(query.getScopeType()))
.eq(query.getDeptId() != null, SmHotelChannelDeptEntity::getDeptId, query.getDeptId())
.eq(StrUtil.isNotBlank(query.getStatus()), SmHotelChannelDeptEntity::getStatus, query.getStatus())
.orderByDesc(SmHotelChannelDeptEntity::getCreateTime));
fillDeptNames(result.getRecords());
fillHotelNames(result.getRecords());
return result;
}
@Override
public List<SmHotelChannelDeptEntity> listEnabled() {
List<SmHotelChannelDeptEntity> list = baseMapper.selectList(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getStatus, "1")
.orderByAsc(SmHotelChannelDeptEntity::getDirection)
.orderByAsc(SmHotelChannelDeptEntity::getChannel));
fillDeptNames(list);
fillHotelNames(list);
return list;
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveMapping(SmHotelChannelDeptEntity entity) {
R<String> valid = validate(entity, null);
if (valid != null) {
return R.failed(valid.getMsg());
}
normalizeEntity(entity);
if (StrUtil.isBlank(entity.getStatus())) {
entity.setStatus("1");
}
return R.ok(baseMapper.insert(entity) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> updateMapping(SmHotelChannelDeptEntity entity) {
if (entity == null || entity.getId() == null) {
return R.failed("缺少主键");
}
SmHotelChannelDeptEntity old = baseMapper.selectById(entity.getId());
if (old == null) {
return R.failed("记录不存在");
}
R<String> valid = validate(entity, entity.getId());
if (valid != null) {
return R.failed(valid.getMsg());
}
normalizeEntity(entity);
return R.ok(baseMapper.updateById(entity) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeMappings(Long[] ids) {
if (ids == null || ids.length == 0) {
return R.failed("请选择要删除的记录");
}
return R.ok(removeByIds(Arrays.asList(ids)));
}
@Override
public Long resolveDeptId(String channel) {
String ch = normalizeCode(channel);
if (ch.isEmpty() || SmHotelDataSource.isLocal(ch)) {
return null;
}
SmHotelChannelDeptEntity row = baseMapper.selectOne(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch)
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.UPSTREAM)
.eq(SmHotelChannelDeptEntity::getStatus, "1")
.last("limit 1"));
return row == null ? null : row.getDeptId();
}
@Override
public R<Long> requireEnabledDeptId(String channel) {
String ch = SmHotelDataSource.normalize(channel);
if (StrUtil.isBlank(ch)) {
return R.failed("请先选择第三方渠道");
}
if (SmHotelDataSource.isLocal(ch)) {
return R.failed("不能选择「本地」渠道,请先在「渠道部门」维护第三方对应关系");
}
Long deptId = resolveDeptId(ch);
if (deptId == null) {
return R.failed("渠道「" + ch + "」尚未维护归属部门,请先到「渠道部门」配置并启用");
}
return R.ok(deptId);
}
@Override
public List<String> listDownstreamPartnersForHotel(Long hotelId, Long deptId, Long districtAdcode, String district) {
List<SmHotelChannelDeptEntity> rows = baseMapper.selectList(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getStatus, "1"));
if (CollUtil.isEmpty(rows)) {
return Collections.emptyList();
}
Set<String> partners = new LinkedHashSet<>();
for (SmHotelChannelDeptEntity row : rows) {
String scope = SmHotelDownstreamScopeType.normalize(row.getScopeType());
if (SmHotelDownstreamScopeType.isHotel(scope)) {
if (hotelId != null && containsHotelId(row.getHotelIds(), hotelId)) {
partners.add(normalizeCode(row.getChannel()));
}
continue;
}
// 按部门
if (deptId == null || row.getDeptId() == null || !deptId.equals(row.getDeptId())) {
continue;
}
if (HotelAreaAdcodeUtil.matchRegion(row.getDistrictAdcode(), row.getDistrict(), districtAdcode, district)) {
partners.add(normalizeCode(row.getChannel()));
}
}
return partners.stream().filter(StrUtil::isNotBlank).collect(Collectors.toList());
}
@Override
public List<Long> listHotelIdsForDownstreamPartner(String partnerCode) {
String partner = normalizeCode(partnerCode);
if (StrUtil.isBlank(partner)) {
return Collections.emptyList();
}
List<SmHotelChannelDeptEntity> scopes = baseMapper.selectList(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getStatus, "1")
.eq(SmHotelChannelDeptEntity::getChannel, partner));
if (CollUtil.isEmpty(scopes)) {
return Collections.emptyList();
}
Set<Long> ids = new LinkedHashSet<>();
for (SmHotelChannelDeptEntity scope : scopes) {
String scopeType = SmHotelDownstreamScopeType.normalize(scope.getScopeType());
if (SmHotelDownstreamScopeType.isHotel(scopeType)) {
if (StrUtil.isBlank(scope.getHotelIds())) {
continue;
}
for (String part : scope.getHotelIds().split(",")) {
String p = StrUtil.trim(part);
if (p.matches("\\d+")) {
ids.add(Long.valueOf(p));
}
}
continue;
}
if (scope.getDeptId() == null) {
continue;
}
List<SmHotelEntity> hotels = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getDistrictAdcode, SmHotelEntity::getDistrict,
SmHotelEntity::getUpstreamStatus)
.eq(SmHotelEntity::getDeptId, scope.getDeptId())
.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
.or()
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")));
if (CollUtil.isEmpty(hotels)) {
continue;
}
for (SmHotelEntity h : hotels) {
if (h == null || h.getId() == null) {
continue;
}
if (HotelAreaAdcodeUtil.matchRegion(scope.getDistrictAdcode(), scope.getDistrict(),
h.getDistrictAdcode(), h.getDistrict())) {
ids.add(h.getId());
}
}
}
if (ids.isEmpty()) {
return Collections.emptyList();
}
// 指定酒店模式可能含已下架:再滤一次上架
List<SmHotelEntity> alive = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId)
.in(SmHotelEntity::getId, ids)
.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
.or()
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")));
return alive.stream().map(SmHotelEntity::getId).filter(Objects::nonNull).distinct()
.collect(Collectors.toList());
}
private void normalizeEntity(SmHotelChannelDeptEntity entity) {
entity.setChannel(normalizeCode(entity.getChannel()));
entity.setDirection(SmHotelChannelDirection.normalize(entity.getDirection()));
if (SmHotelChannelDirection.isUpstream(entity.getDirection())) {
entity.setScopeType(SmHotelDownstreamScopeType.DEPT);
entity.setHotelIds(null);
entity.setDistrictAdcode(null);
entity.setDistrict(null);
return;
}
String scope = SmHotelDownstreamScopeType.normalize(entity.getScopeType());
entity.setScopeType(scope);
if (SmHotelDownstreamScopeType.isHotel(scope)) {
entity.setDeptId(null);
entity.setDistrictAdcode(null);
entity.setDistrict(null);
entity.setHotelIds(normalizeHotelIds(entity.getHotelIds()));
}
else {
entity.setHotelIds(null);
}
}
private R<String> validate(SmHotelChannelDeptEntity entity, Long excludeId) {
if (entity == null) {
return R.failed("参数不能为空");
}
String dir = SmHotelChannelDirection.normalize(entity.getDirection());
String ch = SmHotelDataSource.normalize(entity.getChannel());
if (StrUtil.isBlank(ch)) {
return R.failed(SmHotelChannelDirection.isDownstream(dir) ? "请选择下游伙伴" : "请选择第三方渠道");
}
if (SmHotelChannelDirection.isUpstream(dir) && SmHotelDataSource.isLocal(ch)) {
return R.failed("不能将「本地」配置为第三方渠道");
}
if (SmHotelChannelDirection.isUpstream(dir)) {
if (entity.getDeptId() == null) {
return R.failed("请选择归属部门");
}
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.UPSTREAM)
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed("该上游渠道已配置归属部门,请直接编辑");
}
return null;
}
String scope = SmHotelDownstreamScopeType.normalize(entity.getScopeType());
if (SmHotelDownstreamScopeType.isHotel(scope)) {
String ids = normalizeHotelIds(entity.getHotelIds());
if (StrUtil.isBlank(ids)) {
return R.failed("请选择要授权的酒店");
}
entity.setHotelIds(ids);
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getScopeType, SmHotelDownstreamScopeType.HOTEL)
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed("该下游伙伴已有「按指定酒店」配置,请直接编辑");
}
return null;
}
if (entity.getDeptId() == null) {
return R.failed("请选择要推送的部门");
}
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.and(w -> w.eq(SmHotelChannelDeptEntity::getScopeType, SmHotelDownstreamScopeType.DEPT)
.or()
.isNull(SmHotelChannelDeptEntity::getScopeType)
.or()
.eq(SmHotelChannelDeptEntity::getScopeType, ""))
.eq(SmHotelChannelDeptEntity::getDeptId, entity.getDeptId())
.and(w -> {
if (entity.getDistrictAdcode() == null) {
w.isNull(SmHotelChannelDeptEntity::getDistrictAdcode);
}
else {
w.eq(SmHotelChannelDeptEntity::getDistrictAdcode, entity.getDistrictAdcode());
}
})
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed(entity.getDistrictAdcode() == null
? "该下游伙伴已配置此部门(全部区域),请勿重复"
: "该下游伙伴已配置此部门+区域,请勿重复");
}
return null;
}
private static String normalizeHotelIds(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
Set<String> ids = new LinkedHashSet<>();
for (String part : raw.split("[,,\\s]+")) {
String p = StrUtil.trim(part);
if (StrUtil.isNotBlank(p) && p.matches("\\d+")) {
ids.add(p);
}
}
return ids.isEmpty() ? null : String.join(",", ids);
}
private static boolean containsHotelId(String hotelIds, Long hotelId) {
if (hotelId == null || StrUtil.isBlank(hotelIds)) {
return false;
}
String target = String.valueOf(hotelId);
for (String part : hotelIds.split(",")) {
if (target.equals(StrUtil.trim(part))) {
return true;
}
}
return false;
}
private String normalizeCode(String channel) {
return SmHotelDataSource.normalize(channel).toLowerCase();
}
private void fillDeptNames(List<SmHotelChannelDeptEntity> records) {
if (records == null || records.isEmpty()) {
return;
}
List<Long> deptIds = records.stream().map(SmHotelChannelDeptEntity::getDeptId).filter(Objects::nonNull)
.distinct().collect(Collectors.toList());
Map<Long, String> nameById = new HashMap<>();
if (!deptIds.isEmpty()) {
try {
R<Map<Long, SysDept>> r = remoteDeptService.getDeptByDeptIds(deptIds);
if (r != null && r.isOk() && r.getData() != null) {
r.getData().forEach((id, d) -> {
if (id != null && d != null) {
nameById.put(id, d.getName());
}
});
}
}
catch (Exception e) {
log.warn("|渠道部门|补全部门名失败|{}", e.getMessage());
}
}
for (SmHotelChannelDeptEntity row : records) {
if (row.getDeptId() != null) {
row.setDeptName(nameById.getOrDefault(row.getDeptId(), String.valueOf(row.getDeptId())));
}
row.setChannelLabel(row.getChannel());
if (StrUtil.isBlank(row.getDirection())) {
row.setDirection(SmHotelChannelDirection.UPSTREAM);
}
if (StrUtil.isBlank(row.getScopeType())) {
row.setScopeType(SmHotelDownstreamScopeType.DEPT);
}
}
}
private void fillHotelNames(List<SmHotelChannelDeptEntity> records) {
if (records == null || records.isEmpty()) {
return;
}
Set<Long> allIds = new HashSet<>();
for (SmHotelChannelDeptEntity row : records) {
if (!SmHotelDownstreamScopeType.isHotel(row.getScopeType()) || StrUtil.isBlank(row.getHotelIds())) {
continue;
}
for (String part : row.getHotelIds().split(",")) {
String p = StrUtil.trim(part);
if (p.matches("\\d+")) {
allIds.add(Long.valueOf(p));
}
}
}
if (allIds.isEmpty()) {
return;
}
List<SmHotelEntity> hotels = hotelMapper.selectByIds(allIds);
Map<Long, String> nameById = hotels.stream()
.filter(h -> h != null && h.getId() != null)
.collect(Collectors.toMap(SmHotelEntity::getId,
h -> StrUtil.blankToDefault(h.getHotelName(), String.valueOf(h.getId())), (a, b) -> a));
for (SmHotelChannelDeptEntity row : records) {
if (!SmHotelDownstreamScopeType.isHotel(row.getScopeType()) || StrUtil.isBlank(row.getHotelIds())) {
continue;
}
List<String> names = Arrays.stream(row.getHotelIds().split(","))
.map(StrUtil::trim)
.filter(p -> p.matches("\\d+"))
.map(Long::valueOf)
.map(id -> nameById.getOrDefault(id, String.valueOf(id)))
.collect(Collectors.toList());
row.setHotelNames(String.join("、", names));
}
}
}
@@ -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,123 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamPushLogMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelDownstreamPushLogServiceImpl
extends ServiceImpl<SmHotelDownstreamPushLogMapper, SmHotelDownstreamPushLogEntity>
implements SmHotelDownstreamPushLogService {
@Override
public Page<SmHotelDownstreamPushLogEntity> pageLogs(Page<SmHotelDownstreamPushLogEntity> page,
SmHotelDownstreamPushLogEntity query) {
return baseMapper.selectPage(page, Wrappers.<SmHotelDownstreamPushLogEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(query.getPartnerCode()), SmHotelDownstreamPushLogEntity::getPartnerCode,
query.getPartnerCode())
.eq(query.getSyncLogId() != null, SmHotelDownstreamPushLogEntity::getSyncLogId, query.getSyncLogId())
.eq(query.getHotelId() != null, SmHotelDownstreamPushLogEntity::getHotelId, query.getHotelId())
.eq(StrUtil.isNotBlank(query.getExtHotelId()), SmHotelDownstreamPushLogEntity::getExtHotelId,
query.getExtHotelId())
.eq(StrUtil.isNotBlank(query.getAction()), SmHotelDownstreamPushLogEntity::getAction, query.getAction())
.eq(StrUtil.isNotBlank(query.getSuccess()), SmHotelDownstreamPushLogEntity::getSuccess, query.getSuccess())
.orderByDesc(SmHotelDownstreamPushLogEntity::getCreateTime));
}
@Override
public void record(SmHotelDownstreamPushLogEntity logEntity) {
if (logEntity == null) {
return;
}
try {
baseMapper.insert(logEntity);
}
catch (Exception e) {
log.warn("|下游推送流水|落库失败|partner={}|action={}|err={}", logEntity.getPartnerCode(),
logEntity.getAction(), e.getMessage());
}
}
@Override
public int cleanupOlderThanDays(int retainDays) {
if (retainDays <= 0) {
return 0;
}
LocalDateTime before = LocalDateTime.now().minusDays(retainDays);
// @TableLogic:delete 实际为逻辑删除
return baseMapper.delete(Wrappers.<SmHotelDownstreamPushLogEntity>lambdaQuery()
.lt(SmHotelDownstreamPushLogEntity::getCreateTime, before));
}
@Override
public R<Boolean> removeLog(Long id) {
if (id == null) {
return R.failed("流水ID不能为空");
}
SmHotelDownstreamPushLogEntity row = baseMapper.selectById(id);
if (row == null) {
return R.failed("流水不存在");
}
baseMapper.deleteById(id);
return R.ok(Boolean.TRUE, "已删除");
}
@Override
public R<Integer> removeByCreateTimeRange(String createTimeBegin, String createTimeEnd) {
LocalDateTime begin = parseDateTime(createTimeBegin, true);
LocalDateTime end = parseDateTime(createTimeEnd, false);
if (begin == null && end == null) {
return R.failed("请指定开始或结束时间");
}
if (begin != null && end != null && end.isBefore(begin)) {
return R.failed("结束时间不能早于开始时间");
}
var qw = Wrappers.<SmHotelDownstreamPushLogEntity>lambdaQuery();
if (begin != null) {
qw.ge(SmHotelDownstreamPushLogEntity::getCreateTime, begin);
}
if (end != null) {
qw.le(SmHotelDownstreamPushLogEntity::getCreateTime, end);
}
Long cnt = baseMapper.selectCount(qw);
int n = baseMapper.delete(qw);
log.info("|下游推送流水|按时间删除|begin={}|end={}|deleted={}|matched={}|", begin, end, n, cnt);
return R.ok(n, "已删除 " + n + " 条");
}
/** 支持 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss;日期无时分秒时 begin=00:00:00、end=23:59:59 */
private static LocalDateTime parseDateTime(String raw, boolean beginOfDay) {
if (StrUtil.isBlank(raw)) {
return null;
}
String s = raw.trim().replace('T', ' ');
try {
if (s.length() <= 10) {
LocalDate d = LocalDate.parse(s.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE);
return beginOfDay ? d.atStartOfDay() : d.atTime(23, 59, 59);
}
if (s.length() == 16) {
return LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
}
return LocalDateTime.parse(s.substring(0, Math.min(19, s.length())),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,443 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
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.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelHuanqiOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
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;
import java.util.concurrent.TimeUnit;
/**
* 环启订单拉取/推送落库。上游创建/取消异步触发,保证接收接口秒级返回。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelHuanqiOrderServiceImpl implements SmHotelHuanqiOrderService {
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter D = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final String WATERMARK_KEY = "sm:hotel:huanqi:order:watermark:";
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "sm-hotel-huanqi-order");
t.setDaemon(true);
return t;
});
private final DownstreamHotelClient downstreamClient;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelOrderMapper orderMapper;
private final SmHotelMapper hotelMapper;
private final SmHotelOrderService hotelOrderService;
private final StringRedisTemplate redisTemplate;
@Override
public String resolvePartnerByAppKey(String appKey) {
if (StrUtil.isBlank(appKey)) {
return null;
}
for (String code : channelConfigService.listEnabledDownstreamPartners()) {
HotelDownstreamProperties p = channelConfigService.resolveDownstreamRuntime(code);
if (p != null && appKey.equals(p.getAppKey())) {
return code;
}
}
HotelDownstreamProperties def = channelConfigService.downstreamYamlDefaultsSnapshot();
if (def != null && def.isEnabled() && appKey.equals(def.getAppKey())) {
return "default";
}
return null;
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> applyPayload(String partnerCode, JSONObject payload) {
if (payload == null || payload.isEmpty()) {
return R.failed("报文为空");
}
String orderNo = StrUtil.trim(payload.getStr("orderNo"));
if (StrUtil.isBlank(orderNo)) {
return R.failed("orderNo 不能为空");
}
LocalDateTime changeTime = parseDateTime(payload.getStr("changeTime"));
SmHotelOrderEntity exist = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, orderNo)
.last("limit 1"));
if (exist != null && changeTime != null && exist.getChangeTime() != null
&& changeTime.isBefore(exist.getChangeTime())) {
Map<String, Object> skip = new LinkedHashMap<>(4);
skip.put("applied", false);
skip.put("skipped", true);
skip.put("reason", "过期 changeTime");
skip.put("orderNo", orderNo);
return R.ok(skip);
}
String extHotelId = StrUtil.trim(payload.getStr("extHotelId"));
SmHotelEntity hotel = null;
if (StrUtil.isNotBlank(extHotelId)) {
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, extHotelId)
.last("limit 1"));
}
Integer channelStatus = payload.getInt("status");
Integer channelOrderStatus = payload.getInt("orderStatus");
boolean cancelled = (channelOrderStatus != null && channelOrderStatus == 3)
|| (channelStatus != null && channelStatus == 5);
SmHotelOrderEntity order = exist != null ? exist : new SmHotelOrderEntity();
boolean isNew = exist == null;
order.setPartnerCode(StrUtil.blankToDefault(partnerCode, order.getPartnerCode()));
order.setChannelNo(orderNo);
if (hotel != null) {
order.setHotelId(hotel.getId());
order.setHotelCode(hotel.getHotelCode());
order.setGroupCode(hotel.getGroupCode());
}
else if (StrUtil.isNotBlank(extHotelId)) {
order.setHotelCode(extHotelId);
}
order.setRoomTypeCode(StrUtil.blankToDefault(payload.getStr("extApartmentId"), order.getRoomTypeCode()));
order.setProductCode(StrUtil.blankToDefault(payload.getStr("extProductId"), order.getProductCode()));
order.setCheckInDate(parseDate(payload.getStr("checkIn")));
order.setCheckOutDate(parseDate(payload.getStr("checkOut")));
order.setCount(payload.getInt("roomCnt") == null ? order.getCount() : payload.getInt("roomCnt"));
order.setContactName(StrUtil.blankToDefault(payload.getStr("contactName"), order.getContactName()));
order.setContactPhone(StrUtil.blankToDefault(payload.getStr("contactPhone"), order.getContactPhone()));
if (StrUtil.isNotBlank(payload.getStr("guestInfo"))) {
order.setGuestJson(JSONUtil.toJsonStr(List.of(Map.of("name", payload.getStr("guestInfo")))));
}
if (payload.get("price") != null) {
order.setSellAmount(toDecimal(payload.get("price")));
}
order.setChannelStatus(channelStatus);
order.setChannelOrderStatus(channelOrderStatus);
order.setChangeTime(changeTime);
order.setHuanqiPayload(payload.toString());
order.setReqJson(payload.toString());
if (isNew) {
order.setOrderStatus(cancelled ? "CANCELLED"
: (channelStatus != null && channelStatus == 0 ? "CREATED" : "PENDING"));
order.setCompensateStatus("NONE");
order.setInventoryStatus("NONE");
order.setCompensateRound(0);
orderMapper.insert(order);
}
else {
if (cancelled) {
order.setOrderStatus("CANCELLED");
}
orderMapper.updateById(order);
}
Long orderId = order.getId();
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = 1L;
}
if (cancelled) {
scheduleCancel(tenantId, orderId);
}
else if (channelStatus != null && channelStatus >= 1) {
// 推单履约前再验本地可订,避免环启已售、我方无库存/下架导致扯皮
Map<String, Object> checkBody = new LinkedHashMap<>();
checkBody.put("extHotelId", order.getHotelCode());
checkBody.put("extProductId", order.getProductCode());
checkBody.put("extApartmentId", order.getRoomTypeCode());
checkBody.put("checkIn", order.getCheckInDate() == null ? null : order.getCheckInDate().toString());
checkBody.put("checkOut", order.getCheckOutDate() == null ? null : order.getCheckOutDate().toString());
checkBody.put("roomCnt", order.getCount() == null ? 1 : order.getCount());
// 推单履约预检不带 price:环启 price 是结算价,与本地日历卖价口径不同,避免误拦
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 == null ? "预检失败" : check.getMsg()
: check.getData().get("reasons");
order.setOrderStatus("NEED_MANUAL");
order.setFailMsg(StrUtil.maxLength("推单履约前预检不可订:" + reasons, 500));
orderMapper.updateById(order);
log.warn("|环启订单|预检不可订,转人工|orderNo={}|reasons={}|", orderNo, reasons);
}
else {
scheduleBook(tenantId, orderId);
}
}
Map<String, Object> ok = new LinkedHashMap<>(6);
ok.put("applied", true);
ok.put("skipped", false);
ok.put("orderNo", orderNo);
ok.put("orderId", orderId);
ok.put("isNew", isNew);
ok.put("cancelled", cancelled);
return R.ok(ok);
}
private void scheduleBook(Long tenantId, Long orderId) {
EXECUTOR.execute(() -> TenantBroker.runAs(tenantId, tid -> {
try {
R<Boolean> r = hotelOrderService.bookUpstreamForExisting(orderId);
if (r == null || r.getCode() != 0) {
log.warn("|环启订单|异步上游下单未成功|orderId={}|msg={}|", orderId,
r == null ? "null" : r.getMsg());
}
}
catch (Exception e) {
log.warn("|环启订单|异步上游下单失败|orderId={}|{}", orderId, e.getMessage());
}
}));
}
private void scheduleCancel(Long tenantId, Long orderId) {
EXECUTOR.execute(() -> TenantBroker.runAs(tenantId, tid -> {
try {
hotelOrderService.cancelByAdmin(orderId, "环启取消");
}
catch (Exception e) {
log.warn("|环启订单|异步上游取消失败|orderId={}|{}", orderId, e.getMessage());
}
}));
}
@Override
public R<Map<String, Object>> pullIncremental(String partnerCode) {
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(partnerCode);
if (props == null || !props.isEnabled()) {
return R.failed("下游伙伴未启用: " + partnerCode);
}
if (!props.isOrderPullEnabled()) {
return R.failed("该伙伴已关闭订单拉取: " + partnerCode);
}
downstreamClient.assertPartnerReady(partnerCode);
int pageSize = Math.min(200, Math.max(1, props.getOrderPullPageSize()));
int overlapMin = Math.max(0, props.getOrderPullOverlapMinutes());
String watermark = redisTemplate.opsForValue().get(WATERMARK_KEY + partnerCode);
LocalDateTime start = StrUtil.isNotBlank(watermark) ? parseDateTime(watermark)
: LocalDateTime.now().minusDays(1);
if (start == null) {
start = LocalDateTime.now().minusDays(1);
}
if (overlapMin > 0) {
start = start.minusMinutes(overlapMin);
}
String cursor = null;
String changeEndTime = null;
int page = 0;
int applied = 0;
int skipped = 0;
int fail = 0;
List<String> errors = new ArrayList<>();
while (true) {
page++;
Map<String, Object> req = new LinkedHashMap<>();
req.put("changeStartTime", start.format(DT));
if (StrUtil.isNotBlank(changeEndTime)) {
req.put("changeEndTime", changeEndTime);
}
if (StrUtil.isNotBlank(cursor)) {
req.put("cursor", cursor);
}
req.put("pageSize", pageSize);
JSONObject resp;
try {
resp = downstreamClient.orderQuery(partnerCode, req);
}
catch (Exception e) {
return R.failed("拉取失败: " + e.getMessage());
}
JSONObject data = resp.getJSONObject("data");
if (data == null) {
break;
}
if (StrUtil.isBlank(changeEndTime)) {
changeEndTime = data.getStr("changeEndTime");
}
JSONArray list = data.getJSONArray("list");
if (list != null) {
for (int i = 0; i < list.size(); i++) {
JSONObject one = list.getJSONObject(i);
try {
R<Map<String, Object>> r = applyPayload(partnerCode, one);
if (r != null && r.getCode() == 0 && r.getData() != null) {
if (Boolean.TRUE.equals(r.getData().get("skipped"))) {
skipped++;
}
else if (Boolean.TRUE.equals(r.getData().get("applied"))) {
applied++;
}
}
else {
fail++;
errors.add(one.getStr("orderNo") + ":" + (r == null ? "null" : r.getMsg()));
}
}
catch (Exception ex) {
fail++;
errors.add(one.getStr("orderNo") + ":" + ex.getMessage());
}
}
}
boolean hasMore = Boolean.TRUE.equals(data.getBool("hasMore"));
cursor = data.getStr("nextCursor");
if (!hasMore) {
break;
}
if (StrUtil.isBlank(cursor)) {
errors.add("hasMore=true 但无 nextCursor,停止翻页");
break;
}
if (page > 500) {
errors.add("翻页超过 500,强制停止");
break;
}
}
if (StrUtil.isNotBlank(changeEndTime)) {
redisTemplate.opsForValue().set(WATERMARK_KEY + partnerCode, changeEndTime, 30, TimeUnit.DAYS);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("partnerCode", partnerCode);
result.put("pages", page);
result.put("applied", applied);
result.put("skipped", skipped);
result.put("fail", fail);
result.put("changeEndTime", changeEndTime);
result.put("errors", errors.size() > 20 ? errors.subList(0, 20) : errors);
return R.ok(result);
}
@Override
public R<Map<String, Object>> pullAllEnabledPartners() {
return pullPartners(false);
}
@Override
public R<Map<String, Object>> pullScheduledPartners() {
return pullPartners(true);
}
/**
* @param scheduleOnly true=只拉 join-schedule 的伙伴(定时任务);false=所有允许拉取的伙伴(手动)
*/
private R<Map<String, Object>> pullPartners(boolean scheduleOnly) {
List<String> partners = channelConfigService.listEnabledDownstreamPartners();
if (CollUtil.isEmpty(partners)) {
return R.failed("无启用下游伙伴");
}
int ok = 0;
List<Map<String, Object>> details = new ArrayList<>();
List<String> errors = new ArrayList<>();
for (String p : partners) {
HotelDownstreamProperties props = channelConfigService.resolveDownstreamRuntime(p);
if (props == null || !props.isEnabled() || !props.isOrderPullEnabled()) {
continue;
}
if (scheduleOnly && !props.isOrderPullJoinSchedule()) {
continue;
}
try {
R<Map<String, Object>> r = pullIncremental(p);
if (r != null && r.getCode() == 0) {
ok++;
details.add(r.getData());
}
else {
errors.add(p + ":" + (r == null ? "null" : r.getMsg()));
}
}
catch (Exception e) {
errors.add(p + ":" + e.getMessage());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("successPartners", ok);
result.put("scheduleOnly", scheduleOnly);
result.put("details", details);
result.put("errors", errors);
return R.ok(result);
}
private static LocalDateTime parseDateTime(String s) {
if (StrUtil.isBlank(s)) {
return null;
}
try {
return LocalDateTime.parse(s.trim(), DT);
}
catch (Exception e) {
try {
return LocalDateTime.parse(s.trim().replace(' ', 'T'));
}
catch (Exception ignored) {
return null;
}
}
}
private static LocalDate parseDate(String s) {
if (StrUtil.isBlank(s)) {
return null;
}
try {
return LocalDate.parse(s.trim(), D);
}
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;
}
}
}
@@ -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;
}
}
@@ -10,11 +10,15 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.constant.SmHotelInventoryStatus;
import com.bsd.pjcloud.supermarket.entity.*;
import com.bsd.pjcloud.supermarket.event.HotelUpstreamFailRefundEvent;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGateway;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderGatewayRegistry;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouBookStatus;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
@@ -27,6 +31,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -37,9 +42,11 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -79,11 +86,15 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
private final SmHotelDailyMapper dailyMapper;
private final ShangyouHotelClient upstreamClient;
private final HotelUpstreamOrderGatewayRegistry upstreamGatewayRegistry;
private final SmHotelOrderUpstreamLogService upstreamLogService;
private final ApplicationEventPublisher eventPublisher;
private final SmHotelPriceConfigService priceConfigService;
private final com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties shangyouProperties;
private final SmHotelChannelConfigService channelConfigService;
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
@@ -184,7 +195,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
if (hotel != null) {
o.setHotelName(hotel.getHotelName());
o.setHotelDataSource(SmHotelDataSource.isLocal(hotel.getSheetName()) ? "local" : "shangyou");
o.setHotelDataSource(upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName()));
}
String channelNo = o.getChannelNo();
if (StrUtil.startWithIgnoreCase(channelNo, "APP")) {
@@ -224,6 +235,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
vo.setGuests(parseJsonLenient(order.getGuestJson()));
vo.setReqSnapshot(parseJsonLenient(order.getReqJson()));
vo.setUpstreamResp(parseJsonLenient(order.getUpstreamRespJson()));
vo.setUpstreamLogs(upstreamLogService.listByOrderId(order.getId()));
SmHotelEntity hotel = null;
if (order.getHotelId() != null) {
@@ -240,7 +252,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
hb.setHotelCode(hotel.getHotelCode());
hb.setHotelName(hotel.getHotelName());
hb.setGroupCode(hotel.getGroupCode());
hb.setDataSource(SmHotelDataSource.isLocal(hotel.getSheetName()) ? "local" : "shangyou");
hb.setDataSource(upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName()));
hb.setUpstreamStatus(hotel.getUpstreamStatus());
hb.setCoverImage(hotel.getCoverImage());
hb.setAddress(hotel.getAddress());
@@ -436,11 +448,16 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return finishLocalOrder(order, product);
}
Optional<HotelUpstreamOrderGateway> gatewayOpt = resolveUpstreamGateway(hotel, order, false);
if (gatewayOpt.isEmpty()) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "未支持的上游渠道"));
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
// 1) 下单前 DoubleCheck(文档 checkAvailability)
try {
String availMsg = doubleCheckAvailability(upstreamBody, order.getCount());
String availMsg = doubleCheckAvailability(gateway, order, upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
@@ -454,7 +471,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
// 2) 上游下单
ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody);
HotelUpstreamOrderResult call = invokeCreateOrder(gateway, order, upstreamBody, "CREATE");
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
JSONObject out = buildOut(order);
@@ -464,6 +481,226 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return R.ok(out);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> bookUpstreamForExisting(Long orderId) {
if (orderId == null) {
return R.failed("订单ID为空");
}
SmHotelOrderEntity order = baseMapper.selectById(orderId);
if (order == null) {
return R.failed("订单不存在");
}
if (StrUtil.isNotBlank(order.getPlatformNo())) {
return R.ok(Boolean.TRUE, "已有上游单号");
}
if ("CANCELLED".equals(order.getOrderStatus())) {
return R.failed("订单已取消");
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
if (hotel == null && StrUtil.isNotBlank(order.getHotelCode())) {
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, order.getHotelCode())
.last("limit 1"));
}
if (hotel == null) {
order.setOrderStatus("NEED_MANUAL");
order.setFailMsg("本地无酒店,无法上游下单");
baseMapper.updateById(order);
return R.failed(order.getFailMsg());
}
order.setHotelId(hotel.getId());
SmHotelProductEntity product = null;
if (StrUtil.isNotBlank(order.getProductCode())) {
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
.last("limit 1"));
}
if (SmHotelDataSource.isLocal(hotel.getSheetName())) {
if (product == null) {
return R.failed("本地酒店缺产品");
}
return finishLocalOrder(order, product).getCode() == 0 ? R.ok(Boolean.TRUE) : R.failed("本地落单失败");
}
if (product == null) {
order.setOrderStatus("NEED_MANUAL");
order.setFailMsg("缺产品,无法上游下单");
baseMapper.updateById(order);
return R.failed(order.getFailMsg());
}
Optional<HotelUpstreamOrderGateway> gatewayOpt = resolveUpstreamGateway(hotel, order, false);
if (gatewayOpt.isEmpty()) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "未支持的上游渠道"));
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
Map<String, Object> body = new HashMap<>(8);
body.put("count", order.getCount());
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
try {
String availMsg = doubleCheckAvailability(gateway, order, upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
baseMapper.updateById(order);
return R.failed(availMsg);
}
}
catch (Exception e) {
log.warn("|环启补上游|可定校验异常|channelNo={}|{}", order.getChannelNo(), e.getMessage());
}
HotelUpstreamOrderResult call = invokeCreateOrder(gateway, order, upstreamBody, "CREATE");
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
if ("UPSTREAM_FAIL".equals(order.getOrderStatus()) && !COMP_WAITING.equals(order.getCompensateStatus())) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "上游下单失败"));
}
return R.ok(Boolean.TRUE);
}
@Override
public R<Map<String, Object>> checkBookableForDownstream(Map<String, Object> body) {
Map<String, Object> data = new LinkedHashMap<>(16);
List<String> reasons = new ArrayList<>();
if (body == null || body.isEmpty()) {
return R.failed("报文为空");
}
String extHotelId = StrUtil.trim(str(body.get("extHotelId")));
if (StrUtil.isBlank(extHotelId)) {
extHotelId = StrUtil.trim(str(body.get("hotelCode")));
}
String extProductId = StrUtil.trim(str(body.get("extProductId")));
if (StrUtil.isBlank(extProductId)) {
extProductId = StrUtil.trim(str(body.get("productCode")));
}
String extApartmentId = StrUtil.trim(str(body.get("extApartmentId")));
if (StrUtil.isBlank(extApartmentId)) {
extApartmentId = StrUtil.trim(str(body.get("roomTypeCode")));
}
LocalDate checkIn = parseDate(body.get("checkIn") != null ? body.get("checkIn") : body.get("checkInDate"));
LocalDate checkOut = parseDate(body.get("checkOut") != null ? body.get("checkOut") : body.get("checkOutDate"));
int roomCnt = 1;
Object cntObj = body.get("roomCnt") != null ? body.get("roomCnt") : body.get("count");
if (cntObj != null) {
try {
roomCnt = Math.max(1, Integer.parseInt(String.valueOf(cntObj)));
}
catch (Exception ignored) {
roomCnt = 1;
}
}
BigDecimal expectPrice = toDecimal(body.get("price") != null ? body.get("price") : body.get("totalAmount"));
data.put("extHotelId", extHotelId);
data.put("extProductId", extProductId);
data.put("extApartmentId", extApartmentId);
data.put("checkIn", checkIn == null ? null : checkIn.toString());
data.put("checkOut", checkOut == null ? null : checkOut.toString());
data.put("roomCnt", roomCnt);
if (StrUtil.isBlank(extHotelId)) {
reasons.add("extHotelId 不能为空");
}
if (StrUtil.isBlank(extProductId)) {
reasons.add("extProductId 不能为空");
}
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
reasons.add("checkIn/checkOut 无效");
}
if (!reasons.isEmpty()) {
data.put("bookable", false);
data.put("reasons", reasons);
return R.ok(data);
}
SmHotelEntity hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, extHotelId)
.last("limit 1"));
if (hotel == null) {
reasons.add("本地无此酒店(请先 hotel/sync 建档):" + extHotelId);
data.put("bookable", false);
data.put("reasons", reasons);
return R.ok(data);
}
data.put("hotelId", hotel.getId());
data.put("hotelName", hotel.getHotelName());
data.put("hotelDataSource", hotel.getSheetName());
data.put("upstreamStatus", hotel.getUpstreamStatus());
if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) {
reasons.add("酒店已下架");
}
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotel.getId())
.eq(SmHotelProductEntity::getProductCode, extProductId)
.last("limit 1"));
if (product == null) {
reasons.add("本地无此产品:" + extProductId);
data.put("productExists", false);
data.put("bookable", false);
data.put("reasons", reasons);
return R.ok(data);
}
data.put("productExists", true);
data.put("productName", product.getProductName());
if (StrUtil.isNotBlank(extApartmentId) && StrUtil.isNotBlank(product.getRoomTypeCode())
&& !extApartmentId.equals(product.getRoomTypeCode())) {
reasons.add("房型不匹配:报文=" + extApartmentId + ",产品所属=" + product.getRoomTypeCode());
}
// 日历库存 + 本地卖价合计
BigDecimal localSell = BigDecimal.ZERO;
List<Map<String, Object>> days = new ArrayList<>();
boolean localDs = SmHotelDataSource.isLocal(hotel.getSheetName());
String invMsg = precheckLocalInventory(product.getId(), checkIn, checkOut, roomCnt);
if (invMsg != null) {
reasons.add(invMsg);
}
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, product.getId())
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
Map<String, Object> one = new LinkedHashMap<>(8);
one.put("date", d.toString());
if (row == null) {
one.put("exists", false);
}
else {
int stock = row.getStock() == null ? 0 : row.getStock();
int sold = row.getSold() == null ? 0 : row.getSold();
int remain = Math.max(0, stock - sold);
BigDecimal sell = row.getSellPrice() != null ? row.getSellPrice()
: (product.getSellPrice() != null ? product.getSellPrice() : BigDecimal.ZERO);
localSell = localSell.add(sell.multiply(BigDecimal.valueOf(roomCnt)));
one.put("exists", true);
one.put("dailyStatus", row.getDailyStatus());
one.put("remain", remain);
one.put("sellPrice", sell);
}
days.add(one);
}
data.put("days", days);
data.put("localSellAmount", localSell);
if (expectPrice != null) {
data.put("expectPrice", expectPrice);
BigDecimal diff = localSell.subtract(expectPrice).abs();
boolean match = diff.compareTo(new BigDecimal("0.50")) <= 0;
data.put("priceMatch", match);
data.put("priceDiff", diff);
if (!match) {
reasons.add("结算价不一致:本地合计 " + localSell + ",报文 " + expectPrice + "(容差 0.50 元)");
}
}
// 第三方来源:额外提示上游可定需在真正下单时再 DoubleCheck
data.put("needUpstreamDoubleCheck", !localDs);
data.put("bookable", reasons.isEmpty());
data.put("reasons", reasons);
return R.ok(data);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<JSONObject> createByAdmin(Map<String, Object> body) {
@@ -600,9 +837,14 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return local;
}
Optional<HotelUpstreamOrderGateway> gatewayOpt = resolveUpstreamGateway(hotel, order, false);
if (gatewayOpt.isEmpty()) {
return R.failed(StrUtil.blankToDefault(order.getFailMsg(), "未支持的上游渠道"));
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
try {
String availMsg = doubleCheckAvailability(upstreamBody, order.getCount());
String availMsg = doubleCheckAvailability(gateway, order, upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
@@ -615,7 +857,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setLastCompensateMsg("可定校验异常已忽略: " + StrUtil.sub(e.getMessage(), 0, 200));
}
ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody);
HotelUpstreamOrderResult call = invokeCreateOrder(gateway, order, upstreamBody, "CREATE");
applyCreateResult(order, call, upstreamBody);
baseMapper.updateById(order);
JSONObject out = buildOut(order);
@@ -986,7 +1228,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
private int appPayTimeoutMinutes() {
int minutes = shangyouProperties == null ? 10 : shangyouProperties.getAppPayTimeoutMinutes();
int minutes = channelConfigService.resolveShangyouRuntime().getAppPayTimeoutMinutes();
return minutes > 0 ? minutes : 10;
}
@@ -1046,25 +1288,36 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return local != null && local.isOk();
}
Optional<HotelUpstreamOrderGateway> gatewayOpt = resolveUpstreamGateway(hotel, order, true);
if (gatewayOpt.isEmpty()) {
order.setPayStatus(PAY_PAID);
baseMapper.updateById(order);
scheduleAutoRefundAfterUpstreamFail(order, StrUtil.blankToDefault(order.getFailMsg(), "未支持的上游渠道"));
return true;
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
if (product == null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setPayStatus(PAY_PAID);
order.setFailMsg("支付成功但产品不存在,无法提交上游");
baseMapper.updateById(order);
return false;
scheduleAutoRefundAfterUpstreamFail(order, order.getFailMsg());
return true;
}
Map<String, Object> reqBody = StrUtil.isBlank(order.getReqJson()) ? Map.of()
: JSONUtil.parseObj(order.getReqJson());
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, reqBody);
try {
String availMsg = doubleCheckAvailability(upstreamBody, order.getCount());
String availMsg = doubleCheckAvailability(gateway, order, upstreamBody, order.getCount());
if (availMsg != null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setPayStatus(PAY_PAID);
order.setFailMsg(StrUtil.sub(availMsg, 0, 500));
baseMapper.updateById(order);
return false;
scheduleAutoRefundAfterUpstreamFail(order, availMsg);
return true;
}
}
catch (Exception e) {
@@ -1072,15 +1325,19 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setLastCompensateMsg("可定校验异常已忽略: " + StrUtil.sub(e.getMessage(), 0, 200));
}
ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody);
HotelUpstreamOrderResult call = invokeCreateOrder(gateway, order, upstreamBody, "CREATE");
applyCreateResult(order, call, upstreamBody);
order.setPayStatus(PAY_PAID);
baseMapper.updateById(order);
boolean hardFail = "UPSTREAM_FAIL".equals(order.getOrderStatus())
&& !COMP_WAITING.equals(order.getCompensateStatus());
log.info("|酒店支付成功|上游履约|orderId={}|status={}|hardFail={}|", orderId, order.getOrderStatus(),
hardFail);
// 支付已成功:即使上游暂时失败也返回 true,避免支付回调无限重试;补偿任务会继续处理
if (hardFail) {
scheduleAutoRefundAfterUpstreamFail(order,
StrUtil.blankToDefault(order.getFailMsg(), "上游下单失败自动退款"));
}
log.info("|酒店支付成功|上游履约|orderId={}|channel={}|status={}|hardFail={}|", orderId, gateway.channel(),
order.getOrderStatus(), hardFail);
// 支付已成功:即使上游暂时失败也返回 true,避免支付回调无限重试;补偿/自动退款继续处理
return true;
}
@@ -1361,7 +1618,8 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
/**
* @return null=通过;非空=失败原因
*/
private String doubleCheckAvailability(com.alibaba.fastjson.JSONObject upstreamBody, Integer roomCount) {
private String doubleCheckAvailability(HotelUpstreamOrderGateway gateway, SmHotelOrderEntity order,
com.alibaba.fastjson.JSONObject upstreamBody, Integer roomCount) {
com.alibaba.fastjson.JSONObject req = new com.alibaba.fastjson.JSONObject();
req.put("hotelId", upstreamBody.getString("hotelId"));
req.put("roomTypeId", upstreamBody.getString("roomTypeId"));
@@ -1373,7 +1631,21 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (StrUtil.isNotBlank(upstreamBody.getString("activityId"))) {
req.put("activityId", upstreamBody.getString("activityId"));
}
com.alibaba.fastjson.JSONObject content = upstreamClient.checkAvailability(req);
long t0 = System.currentTimeMillis();
com.alibaba.fastjson.JSONObject content;
try {
content = gateway.checkAvailability(req);
}
catch (Exception e) {
upstreamLogService.saveSimple(order.getId(), gateway.channel(), "CHECK_AVAIL", false, "EX", e.getMessage(),
req.toJSONString(), null, System.currentTimeMillis() - t0);
throw e;
}
upstreamLogService.saveSimple(order.getId(), gateway.channel(), "CHECK_AVAIL", true, "200", "ok",
req.toJSONString(), content == null ? null : content.toJSONString(), System.currentTimeMillis() - t0);
if (content == null) {
content = new com.alibaba.fastjson.JSONObject();
}
com.alibaba.fastjson.JSONObject roomCountObj = content.getJSONObject("roomCount");
int need = roomCount == null ? 1 : roomCount;
if (roomCountObj != null) {
@@ -1407,7 +1679,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return null;
}
private void applyCreateResult(SmHotelOrderEntity order, ShangyouOrderCallResult call,
private void applyCreateResult(SmHotelOrderEntity order, HotelUpstreamOrderResult call,
com.alibaba.fastjson.JSONObject upstreamBody) {
if (call.getRaw() != null) {
order.setUpstreamRespJson(call.getRaw().toJSONString());
@@ -1441,7 +1713,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
order.setPlatformNo(call.orderId());
order.setPayStatus(call.payStatus());
if (StrUtil.isNotBlank(call.payStatus())) {
order.setPayStatus(call.payStatus());
}
String book = call.bookStatus();
applyBookStatus(order, book, call.payStatus());
if (ShangyouBookStatus.isProcessing(book) || StrUtil.isBlank(book)) {
@@ -1528,11 +1802,27 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
int nextRound = round + 1;
log.info("|酒店订单补偿|开始|orderId={}|channelNo={}|round={}|", orderId, order.getChannelNo(), nextRound);
ShangyouOrderCallResult query = upstreamClient.queryByRequestNoRaw(order.getChannelNo());
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
Optional<HotelUpstreamOrderGateway> gatewayOpt = hotel == null ? Optional.empty()
: upstreamGatewayRegistry.find(hotel.getSheetName());
if (gatewayOpt.isEmpty()) {
bumpOrManual(order, nextRound, "补偿时无可用上游网关");
return;
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult query = gateway.queryByRequestNo(order.getChannelNo());
upstreamLogService.saveCall(order.getId(), gateway.channel(), "QUERY", query, order.getChannelNo(),
System.currentTimeMillis() - t0);
if (query.isUncertain() || (!query.isBizOk() && StrUtil.isBlank(query.orderId()))) {
// 查不到确定结果:文档允许用原 requestNo 重试 order/add
if (nextRound <= 2) {
ShangyouOrderCallResult retryAdd = retryOrderAdd(order);
HotelUpstreamOrderResult retryAdd = retryOrderAdd(gateway, order);
if (retryAdd != null && !retryAdd.isUncertain() && !retryAdd.isBizOk() && !retryAdd.isPayFail3028()) {
applyHardUpstreamFailFromCompensate(order, retryAdd);
return;
}
if (retryAdd != null && (retryAdd.isBizOk() || StrUtil.isNotBlank(retryAdd.orderId()))) {
applyCompensateSuccess(order, retryAdd, nextRound, "重试 order/add");
return;
@@ -1544,7 +1834,11 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (StrUtil.isBlank(query.orderId()) && StrUtil.isBlank(query.bookStatus())) {
// 尚无订单:尝试重试下单
ShangyouOrderCallResult retryAdd = retryOrderAdd(order);
HotelUpstreamOrderResult retryAdd = retryOrderAdd(gateway, order);
if (retryAdd != null && !retryAdd.isUncertain() && !retryAdd.isBizOk() && !retryAdd.isPayFail3028()) {
applyHardUpstreamFailFromCompensate(order, retryAdd);
return;
}
if (retryAdd != null && (retryAdd.isBizOk() || StrUtil.isNotBlank(retryAdd.orderId()))) {
applyCompensateSuccess(order, retryAdd, nextRound, "查询无单后重试 order/add");
return;
@@ -1556,7 +1850,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
applyCompensateSuccess(order, query, nextRound, "queryByRequestNo");
}
private ShangyouOrderCallResult retryOrderAdd(SmHotelOrderEntity order) {
private HotelUpstreamOrderResult retryOrderAdd(HotelUpstreamOrderGateway gateway, SmHotelOrderEntity order) {
try {
Map<String, Object> body = StrUtil.isBlank(order.getReqJson()) ? Map.of()
: JSONUtil.parseObj(order.getReqJson());
@@ -1568,15 +1862,30 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
.last("limit 1"));
}
com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, body);
return upstreamClient.createOrderRaw(upstreamBody);
return invokeCreateOrder(gateway, order, upstreamBody, "RETRY_ADD");
}
catch (Exception e) {
log.warn("|酒店订单补偿|重试下单异常|channelNo={}|{}", order.getChannelNo(), e.getMessage());
return ShangyouOrderCallResult.uncertain(e.getMessage(), null);
return HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
}
private void applyCompensateSuccess(SmHotelOrderEntity order, ShangyouOrderCallResult call, int round,
/** 补偿中明确业务失败:已支付则自动退款;NEED_MANUAL(不明)不走此路径 */
private void applyHardUpstreamFailFromCompensate(SmHotelOrderEntity order, HotelUpstreamOrderResult call) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg(StrUtil.sub("补偿确认失败 code=" + call.getCode() + " " + call.getMessage(), 0, 500));
order.setCompensateStatus(COMP_NONE);
order.setNextCompensateTime(null);
if (call.getRaw() != null) {
order.setUpstreamRespJson(call.getRaw().toJSONString());
}
baseMapper.updateById(order);
if (PAY_PAID.equals(order.getPayStatus())) {
scheduleAutoRefundAfterUpstreamFail(order, order.getFailMsg());
}
}
private void applyCompensateSuccess(SmHotelOrderEntity order, HotelUpstreamOrderResult call, int round,
String source) {
if (call.getRaw() != null) {
order.setUpstreamRespJson(call.getRaw().toJSONString());
@@ -1706,21 +2015,11 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (StrUtil.isBlank(order.getPlatformNo())) {
return R.failed("尚无上游订单号,无法取消;若在补偿中请等待或人工确认");
}
try {
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
if (!ok) {
return R.failed("上游取消返回失败");
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
baseMapper.updateById(order);
return R.ok(buildOut(order));
}
catch (Exception e) {
return R.failed("上游取消失败: " + e.getMessage());
R<Boolean> cancelR = cancelUpstreamOrder(order, "下游取消");
if (cancelR == null || !cancelR.isOk()) {
return R.failed(cancelR == null ? "上游取消失败" : cancelR.getMsg());
}
return R.ok(buildOut(baseMapper.selectById(order.getId())));
}
@Override
@@ -1736,11 +2035,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
// 有平台号则刷新一次上游状态
try {
if (StrUtil.isNotBlank(order.getPlatformNo())) {
com.alibaba.fastjson.JSONObject content = upstreamClient.queryOrder(order.getPlatformNo(),
order.getHotelCode());
applyBookStatus(order, content.getString("bookStatus"), content.getString("payStatus"));
order.setUpstreamRespJson(content.toJSONString());
baseMapper.updateById(order);
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
Optional<HotelUpstreamOrderGateway> gatewayOpt = hotel == null ? Optional.empty()
: upstreamGatewayRegistry.find(hotel.getSheetName());
if (gatewayOpt.isPresent()) {
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult q = gateway.queryOrder(order.getPlatformNo(), order.getHotelCode());
upstreamLogService.saveCall(order.getId(), gateway.channel(), "QUERY", q, order.getPlatformNo(),
System.currentTimeMillis() - t0);
if (q.getContent() != null) {
applyBookStatus(order, q.getContent().getString("bookStatus"),
q.getContent().getString("payStatus"));
order.setUpstreamRespJson(q.getContent().toJSONString());
baseMapper.updateById(order);
}
}
}
else if (COMP_WAITING.equals(order.getCompensateStatus())
|| "PENDING_CONFIRM".equals(order.getOrderStatus())) {
@@ -1803,24 +2113,11 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
baseMapper.updateById(order);
return R.ok(true, PAY_UNPAID.equals(order.getPayStatus()) ? "未支付订单已取消" : "本地订单已关闭");
}
try {
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
if (!ok) {
return R.failed("上游取消返回失败");
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
if (StrUtil.isNotBlank(reason)) {
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return R.ok(true);
}
catch (Exception e) {
return R.failed("取消失败: " + e.getMessage());
R<Boolean> cancelR = cancelUpstreamOrder(order, reason);
if (cancelR == null || !cancelR.isOk()) {
return R.failed(cancelR == null ? "取消失败" : cancelR.getMsg());
}
return R.ok(true);
}
@Override
@@ -1952,7 +2249,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
boolean isGroup = resolveOrderIsGroup(downstreamBody);
String guarantee = StrUtil.blankToDefault(str(downstreamBody.get("guaranteeMode")),
StrUtil.blankToDefault(shangyouProperties.getOrderGuaranteeMode(), "NONE"));
StrUtil.blankToDefault(channelConfigService.resolveShangyouRuntime().getOrderGuaranteeMode(), "NONE"));
req.put("guaranteeMode", guarantee);
req.put("isGroup", isGroup);
req.put("apiType", "INTERFACE");
@@ -2027,7 +2324,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (v != null && StrUtil.isNotBlank(String.valueOf(v))) {
return Boolean.parseBoolean(String.valueOf(v));
}
return shangyouProperties.isOrderIsGroup();
return channelConfigService.resolveShangyouRuntime().isOrderIsGroup();
}
/**
@@ -2126,4 +2423,108 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
return new BigDecimal(String.valueOf(o));
}
/**
* 解析上游网关;未注册渠道时落 UPSTREAM_FAIL。
*
* @param scheduleRefundOnUnsupported App 已付场景由调用方在 empty 后自行退款;此处仅落失败状态
*/
private Optional<HotelUpstreamOrderGateway> resolveUpstreamGateway(SmHotelEntity hotel, SmHotelOrderEntity order,
boolean scheduleRefundOnUnsupported) {
if (hotel == null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("酒店不存在,无法提交上游");
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
return Optional.empty();
}
if (upstreamGatewayRegistry.isUnsupportedChannel(hotel.getSheetName())) {
String ch = upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName());
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("未接入的上游渠道: " + ch);
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
upstreamLogService.saveSimple(order.getId(), ch, "CREATE", false, "UNSUPPORTED",
order.getFailMsg(), null, null, 0L);
if (scheduleRefundOnUnsupported) {
// 退款由调用方在置 PAID 后触发
}
return Optional.empty();
}
Optional<HotelUpstreamOrderGateway> gateway = upstreamGatewayRegistry.find(hotel.getSheetName());
if (gateway.isEmpty()) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("本地酒店不应走上游履约");
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
}
return gateway;
}
private HotelUpstreamOrderResult invokeCreateOrder(HotelUpstreamOrderGateway gateway, SmHotelOrderEntity order,
com.alibaba.fastjson.JSONObject upstreamBody, String action) {
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult call;
try {
call = gateway.createOrder(upstreamBody);
}
catch (Exception e) {
call = HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
upstreamLogService.saveCall(order.getId(), gateway.channel(), action, call,
upstreamBody == null ? null : upstreamBody.toJSONString(), System.currentTimeMillis() - t0);
return call;
}
/** 事务提交后触发自动退款(仅已支付订单) */
private void scheduleAutoRefundAfterUpstreamFail(SmHotelOrderEntity order, String reason) {
if (order == null || order.getId() == null) {
return;
}
SmHotelOrderEntity latest = baseMapper.selectById(order.getId());
if (latest == null) {
return;
}
if (!PAY_PAID.equals(latest.getPayStatus())) {
log.info("|酒店自动退款|跳过非已支付|orderId={}|payStatus={}|", latest.getId(), latest.getPayStatus());
return;
}
Long tenantId = latest.getTenantId() != null ? latest.getTenantId() : TenantContextHolder.getTenantId();
eventPublisher.publishEvent(new HotelUpstreamFailRefundEvent(latest.getId(), tenantId,
StrUtil.blankToDefault(reason, "上游下单失败自动退款")));
}
private R<Boolean> cancelUpstreamOrder(SmHotelOrderEntity order, String reason) {
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
Optional<HotelUpstreamOrderGateway> gatewayOpt = hotel == null ? Optional.empty()
: upstreamGatewayRegistry.find(hotel.getSheetName());
if (gatewayOpt.isEmpty()) {
return R.failed("无可用上游网关,无法取消");
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult call = gateway.cancelOrder(order.getPlatformNo(), order.getHotelCode());
upstreamLogService.saveCall(order.getId(), gateway.channel(), "CANCEL", call,
order.getPlatformNo(), System.currentTimeMillis() - t0);
if (call.isUncertain()) {
return R.failed("上游取消不确定: " + call.getMessage());
}
if (!call.isBizOk()) {
return R.failed(StrUtil.blankToDefault(call.getMessage(), "上游取消返回失败"));
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
if (StrUtil.isNotBlank(reason)) {
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return R.ok(true);
}
@Override
public List<SmHotelOrderUpstreamLogEntity> listUpstreamLogs(Long orderId) {
return upstreamLogService.listByOrderId(orderId);
}
}
@@ -0,0 +1,82 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderUpstreamLogMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
@Slf4j
@Service
public class SmHotelOrderUpstreamLogServiceImpl
extends ServiceImpl<SmHotelOrderUpstreamLogMapper, SmHotelOrderUpstreamLogEntity>
implements SmHotelOrderUpstreamLogService {
private static final int JSON_MAX = 8000;
@Override
public void saveCall(Long orderId, String channel, String action, HotelUpstreamOrderResult result, String reqJson,
long costMs) {
if (orderId == null || result == null) {
return;
}
boolean success = result.isBizOk() && !result.isUncertain();
String resp = null;
if (result.getRaw() != null) {
resp = result.getRaw().toJSONString();
}
else if (result.getContent() != null) {
resp = result.getContent().toJSONString();
}
saveSimple(orderId, channel, action, success, result.getCode(),
StrUtil.blankToDefault(result.getMessage(), result.getExceptionMsg()), reqJson, resp, costMs);
}
@Override
public void saveSimple(Long orderId, String channel, String action, boolean success, String bizCode, String message,
String reqJson, String respJson, Long costMs) {
if (orderId == null) {
return;
}
try {
SmHotelOrderUpstreamLogEntity row = new SmHotelOrderUpstreamLogEntity();
row.setOrderId(orderId);
row.setChannel(StrUtil.blankToDefault(channel, "-"));
row.setAction(StrUtil.blankToDefault(action, "-"));
row.setSuccess(success ? "1" : "0");
row.setBizCode(StrUtil.sub(bizCode, 0, 64));
row.setMessage(StrUtil.sub(message, 0, 500));
row.setReqJson(clip(reqJson));
row.setRespJson(clip(respJson));
row.setCostMs(costMs);
baseMapper.insert(row);
}
catch (Exception e) {
log.warn("|酒店上游流水|写入失败|orderId={}|action={}|{}", orderId, action, e.getMessage());
}
}
@Override
public List<SmHotelOrderUpstreamLogEntity> listByOrderId(Long orderId) {
if (orderId == null) {
return Collections.emptyList();
}
return baseMapper.selectList(Wrappers.<SmHotelOrderUpstreamLogEntity>lambdaQuery()
.eq(SmHotelOrderUpstreamLogEntity::getOrderId, orderId)
.orderByAsc(SmHotelOrderUpstreamLogEntity::getCreateTime));
}
private static String clip(String s) {
if (s == null) {
return null;
}
return s.length() <= JSON_MAX ? s : s.substring(0, JSON_MAX) + "...(truncated)";
}
}
@@ -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
@@ -4,15 +4,19 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.dto.SmHotelLocalDailyBatchDTO;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamPushLogContext;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelSyncLogMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import com.bsd.pjcloud.common.core.util.R;
@@ -50,6 +54,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private static final String TYPE_DETAIL = "DETAIL_PULL";
/** 按下游范围映射全量推送 */
private static final String TYPE_DOWNSTREAM_PUSH = "DOWNSTREAM_PUSH";
/** 本地酒店按配置补齐日历窗口 */
private static final String TYPE_LOCAL_ROLL = "LOCAL_ROLL";
@@ -68,12 +75,19 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return t;
});
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final ShangyouHotelClient upstreamClient;
/** 库表覆盖 YAML 后的生效配置(日更开关等热更新) */
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
private final SmHotelMidService midService;
private final SmHotelChannelDeptService channelDeptService;
private final SmHotelMapper hotelMapper;
private final SmHotelSyncLogMapper syncLogMapper;
@@ -90,12 +104,30 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.failed("已有列表同步任务进行中,请稍候查看进度(若长时间卡住请在同步日志点「终止」)");
}
String source = resolveImportDataSource(dataSource, triggerType);
if ("MANUAL".equalsIgnoreCase(StrUtil.trim(triggerType))) {
R<Long> deptR = channelDeptService.requireEnabledDeptId(source);
if (deptR == null || !deptR.isOk()) {
return R.failed(deptR == null ? "渠道部门未配置" : deptR.getMsg());
}
}
else if (StrUtil.isNotBlank(source) && channelDeptService.resolveDeptId(source) == null) {
log.warn("|酒店列表同步|定时任务渠道未配置部门|channel={}|将导入但 deptId 为空|", source);
}
if (StrUtil.isBlank(source)) {
return R.failed("请选择数据来源(字典 sm_hotel_data_source)");
}
if (SmHotelDataSource.isLocal(source)) {
return R.failed("上游同步不能使用「本地」数据来源");
}
if ("MANUAL".equalsIgnoreCase(StrUtil.trim(triggerType))) {
R<Long> deptR = channelDeptService.requireEnabledDeptId(source);
if (deptR == null || !deptR.isOk()) {
return R.failed(deptR == null ? "渠道部门未配置" : deptR.getMsg());
}
}
else if (channelDeptService.resolveDeptId(source) == null) {
log.warn("|酒店列表同步|定时任务渠道未配置部门|channel={}|导入时可能无 deptId|", source);
}
// 供差集「补导剩余新增」复用本次选择的来源
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_IMPORT_DATA_SOURCE, source, 2, TimeUnit.DAYS);
@@ -107,7 +139,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
@@ -119,7 +151,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public R<SmHotelSyncLogEntity> startPriceRollAsync(String triggerType) {
if (!properties.isPriceRollDailyEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) {
if (!props().isPriceRollDailyEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) {
return R.failed("滚价未启用");
}
abortStaleRunningSyncLogs();
@@ -136,7 +168,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
syncLogMapper.insert(logEntity);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
@@ -146,7 +178,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public R<SmHotelSyncLogEntity> startLocalDailyRollAsync(String triggerType) {
if (!properties.isLocalDailyRollEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) {
if (!props().isLocalDailyRollEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) {
return R.failed("本地自动滚加未启用");
}
abortStaleRunningSyncLogs();
@@ -163,7 +195,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
syncLogMapper.insert(logEntity);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
@@ -172,7 +204,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
@Override
public R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType) {
public R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType, String dataSource) {
abortStaleRunningSyncLogs();
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_DETAIL)
@@ -180,31 +212,78 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
if (running != null && running > 0) {
return R.failed("已有全量详情拉取任务进行中,请打开「同步日志」查看进度(若长时间卡住请点「终止」)");
}
R<Long> deptR = channelDeptService.requireEnabledDeptId(dataSource);
if (deptR == null || !deptR.isOk() || deptR.getData() == null) {
return R.failed(deptR == null ? "渠道部门未配置" : deptR.getMsg());
}
String channel = SmHotelDataSource.normalize(dataSource).toLowerCase();
Long deptId = deptR.getData();
String quotaErr = checkFullPriceSyncQuota();
if (quotaErr != null) {
return R.failed(quotaErr);
}
int maxHotels = limit != null && limit > 0 ? limit : properties.getDetailPullMaxHotels();
int maxHotels = limit != null && limit > 0 ? limit : props().getDetailPullMaxHotels();
SmHotelSyncLogEntity logEntity = newLog(TYPE_DETAIL, triggerType);
logEntity.setStage("QUEUED");
logEntity.setStageMsg(maxHotels > 0 ? "已提交,最多处理 " + maxHotels + " 家启用酒店…"
: "已提交,将拉取全部启用酒店详情(耗时长,请看进度)…");
logEntity.setStageMsg((maxHotels > 0 ? "已提交,最多处理 " + maxHotels + " 家" : "已提交,将拉取")
+ "「" + channel + "」启用酒店详情,归属部门=" + deptId + "…");
logEntity.setProgressPct(1);
syncLogMapper.insert(logEntity);
markFullPriceSyncStarted();
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
final int cap = maxHotels;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDetailPull(logId, cap)));
final String ch = channel;
final Long dept = deptId;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDetailPull(logId, cap, ch, dept)));
return R.ok(logEntity, "已提交全量详情拉取");
}
@Override
public R<SmHotelSyncLogEntity> startDownstreamScopePushAsync(String partnerCode, String triggerType) {
abortStaleRunningSyncLogs();
String partner = SmHotelDataSource.normalize(partnerCode).toLowerCase();
if (StrUtil.isBlank(partner)) {
return R.failed("请选择下游伙伴");
}
if (!channelConfigService.listEnabledDownstreamPartners().contains(partner)) {
return R.failed("下游伙伴「" + partner + "」未启用或不存在,请先到「渠道对接配置」维护");
}
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_DOWNSTREAM_PUSH)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有下游全量推送任务进行中,请到「上游同步日志」查看进度");
}
List<Long> hotelIds = channelDeptService.listHotelIdsForDownstreamPartner(partner);
if (CollUtil.isEmpty(hotelIds)) {
return R.failed("下游「" + partner + "」授权范围内没有上架酒店(请检查渠道部门映射与酒店上架状态)");
}
SmHotelSyncLogEntity logEntity = newLog(TYPE_DOWNSTREAM_PUSH, triggerType);
logEntity.setStage("QUEUED");
logEntity.setStageMsg("已提交:向下游「" + partner + "」推送 " + hotelIds.size() + " 家酒店(仅本地数据)…");
logEntity.setProgressPct(1);
logEntity.setLocalCount((long) hotelIds.size());
syncLogMapper.insert(logEntity);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
final String partnerFinal = partner;
final List<Long> ids = List.copyOf(hotelIds);
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDownstreamScopePush(logId, partnerFinal, ids)));
return R.ok(logEntity, "已提交下游全量推送,共 " + hotelIds.size() + " 家");
}
@Override
public R<SmHotelSyncLogEntity> startRetryFailedAsync(Long sourceLogId) {
if (sourceLogId == null) {
@@ -243,7 +322,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
@@ -351,8 +430,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
redisTemplate.expire(ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET, 2, TimeUnit.DAYS);
int imported = 0;
if (autoImport && properties.isAutoImportNewHotels()) {
int batchSize = Math.max(1, properties.getAutoImportBatchSize());
if (autoImport && props().isAutoImportNewHotels()) {
int batchSize = Math.max(1, props().getAutoImportBatchSize());
int round = 0;
// 按批循环直到差集清空;batchSize 只控内存,不再当「只导一批」上限
while (round < 10000) {
@@ -381,12 +460,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
int offlineMarked = 0;
if (properties.isAutoMarkOfflineHotels()) {
if (props().isAutoMarkOfflineHotels()) {
progress(logId, 80, "MARK_OFFLINE", "正在将上游下线酒店标为停用…");
offlineMarked = markOfflineFromDiff();
}
int reactivated = 0;
if (properties.isAutoReactivateOnlineHotels()) {
if (props().isAutoReactivateOnlineHotels()) {
progress(logId, 90, "REACTIVATE", "正在恢复重新授权的酒店…");
reactivated = reactivateBackOnlineHotels();
}
@@ -423,10 +502,10 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
long t0 = System.currentTimeMillis();
try {
progress(logId, 5, "ROLL", "开始滚动拉取第 N 天房价…");
int offset = Math.max(1, properties.getPriceRollDayOffset());
int offset = Math.max(1, props().getPriceRollDayOffset());
LocalDate day = LocalDate.now().plusDays(offset);
LocalDate end = day.plusDays(1);
int batchSize = Math.max(20, properties.getPriceRollBatchSize());
int batchSize = Math.max(20, props().getPriceRollBatchSize());
long lastId = 0L;
int ok = 0;
int fail = 0;
@@ -480,7 +559,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
fail++;
appendFail(failLines, h, e.getMessage());
}
int intervalMs = Math.max(0, properties.getPriceRollIntervalMs());
int intervalMs = Math.max(0, props().getPriceRollIntervalMs());
if (intervalMs > 0) {
try {
Thread.sleep(intervalMs);
@@ -530,7 +609,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
long t0 = System.currentTimeMillis();
try {
progress(logId, 5, "ROLL", "开始本地滚加(补齐缺失日历)…");
int batchSize = Math.max(20, properties.getPriceRollBatchSize());
int batchSize = Math.max(20, props().getPriceRollBatchSize());
long lastId = 0L;
int ok = 0;
int fail = 0;
@@ -631,25 +710,132 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
}
/** 按范围映射向指定下游逐家推送本地酒店数据(不拉上游)。 */
private void runDownstreamScopePush(Long logId, String partnerCode, List<Long> hotelIds) {
long t0 = System.currentTimeMillis();
HotelDownstreamProperties dsProps = channelConfigService.resolveDownstreamRuntime(partnerCode);
int sampleMax = dsProps == null ? 20 : Math.max(0, dsProps.getPushLogSampleSuccessMax());
DownstreamPushLogContext.begin(logId, sampleMax);
try {
int total = hotelIds == null ? 0 : hotelIds.size();
int ok = 0;
int fail = 0;
int skip = 0;
int processed = 0;
List<String> failLines = new ArrayList<>();
progress(logId, 5, "PUSH", "开始向下游「" + partnerCode + "」推送,共 " + total + " 家…");
if (CollUtil.isNotEmpty(hotelIds)) {
for (Long hotelId : hotelIds) {
processed++;
SmHotelEntity h = hotelMapper.selectById(hotelId);
if (h == null) {
skip++;
continue;
}
try {
R<Map<String, Object>> r = midService.pushToDownstream(hotelId, partnerCode);
if (r != null && r.getCode() == 0) {
// 下架/无产品/无日历等预检跳过:算 skip,不算成功(对方也卖不了)
if (isDownstreamPushSkipped(r.getData())) {
skip++;
}
else {
ok++;
}
}
else {
fail++;
appendFail(failLines, h, r == null ? "返回为空" : StrUtil.blankToDefault(r.getMsg(), "业务失败"));
}
}
catch (Exception e) {
fail++;
appendFail(failLines, h, e.getMessage());
log.warn("|下游全量推送|单店失败|partner={}|hotelId={}|{}", partnerCode, hotelId, e.getMessage());
}
int pct = total <= 0 ? 50 : Math.min(95, 5 + (int) (processed * 90L / total));
progress(logId, pct, "PUSH",
"推送中 " + processed + "/" + total + " 成功 " + ok + " 失败 " + fail + " 跳过 " + skip
+ " 当前 " + StrUtil.blankToDefault(h.getHotelName(), h.getHotelCode()));
if (isAborted(logId)) {
failLog(logId, "任务已终止", System.currentTimeMillis() - t0);
return;
}
}
}
SmHotelSyncLogEntity done = syncLogMapper.selectById(logId);
if (done == null) {
return;
}
done.setStatus(STATUS_SUCCESS);
done.setStage("DONE");
done.setStageMsg("下游「" + partnerCode + "」全量推送完成:成功 " + ok + " / 失败 " + fail + " / 跳过 " + skip
+ "(成功含可售推与关房推;跳过=无产品等);明细见下游推送日志(同步日志ID=" + logId + ")");
done.setProgressPct(100);
done.setSuccessCount(ok);
done.setFailCount(fail);
done.setSkipCount(skip);
done.setLocalCount((long) total);
applyFailDetail(done, failLines, fail);
done.setCostMs(System.currentTimeMillis() - t0);
done.setFinishTime(LocalDateTime.now());
done.setUpdateTime(LocalDateTime.now());
syncLogMapper.updateById(done);
log.info("|下游全量推送|完成|logId={}|partner={}|ok={}|fail={}|skip={}|costMs={}|", logId, partnerCode, ok,
fail, skip, done.getCostMs());
}
catch (Throwable e) {
log.error("|下游全量推送|失败|logId={}|partner={}|", logId, partnerCode, e);
failLog(logId, e.getMessage(), System.currentTimeMillis() - t0);
}
finally {
DownstreamPushLogContext.end();
}
}
/** pushToDownstream 预检跳过时 data.skipped=true */
private static boolean isDownstreamPushSkipped(Object data) {
if (!(data instanceof Map<?, ?> map)) {
return false;
}
Object skipped = map.get("skipped");
if (skipped instanceof Boolean b) {
return b;
}
return "true".equalsIgnoreCase(String.valueOf(skipped));
}
/** 人工终止后状态已非 RUNNING,后续循环应退出 */
private boolean isAborted(Long logId) {
SmHotelSyncLogEntity cur = syncLogMapper.selectById(logId);
return cur == null || !STATUS_RUNNING.equals(cur.getStatus());
}
/**
* 对商游来源启用酒店逐家 pullFromUpstream(综合信息 + 未来 price-days 房价)。
* 本地及其它渠道酒店一律不进此任务。
*/
private void runDetailPull(Long logId, int maxHotels) {
private void runDetailPull(Long logId, int maxHotels, String channel, Long deptId) {
long t0 = System.currentTimeMillis();
String ch = SmHotelDataSource.normalize(channel).toLowerCase();
try {
progress(logId, 3, "SCAN", "正在统计商游启用酒店…");
// 将该渠道酒店统一归属到维护的部门(便于支付商户/数据权限)
if (deptId != null && StrUtil.isNotBlank(ch)) {
hotelMapper.update(null, Wrappers.<SmHotelEntity>lambdaUpdate()
.set(SmHotelEntity::getDeptId, deptId)
.apply(channelSheetSql(ch), ch)
.and(w -> w.isNull(SmHotelEntity::getDeptId).or().ne(SmHotelEntity::getDeptId, deptId)));
}
progress(logId, 3, "SCAN", "正在统计「" + ch + "」启用酒店…");
Long totalHint = hotelMapper.selectCount(Wrappers.<SmHotelEntity>lambdaQuery()
.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
.or()
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"))
.and(w -> w.isNull(SmHotelEntity::getSheetName)
.or()
.eq(SmHotelEntity::getSheetName, SmHotelDataSource.SHANGYOU)));
.apply(channelSheetSql(ch), ch));
long totalAll = totalHint == null ? 0L : totalHint;
long target = maxHotels > 0 ? Math.min(totalAll, maxHotels) : totalAll;
int batchSize = Math.max(20, properties.getPriceRollBatchSize());
int intervalMs = Math.max(0, properties.getDetailPullIntervalMs());
int batchSize = Math.max(20, props().getPriceRollBatchSize());
int intervalMs = Math.max(0, props().getDetailPullIntervalMs());
long lastId = 0L;
int ok = 0;
int fail = 0;
@@ -657,8 +843,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
int processed = 0;
List<String> failLines = new ArrayList<>();
progress(logId, 5, "PULL", "开始拉取详情:目标约 " + target + " 家商游酒店(price-days="
+ properties.getPriceDays() + ")…");
progress(logId, 5, "PULL", "开始拉取详情:目标约 " + target + " 家「" + ch + "」酒店(price-days="
+ props().getPriceDays() + ")…");
outer:
while (true) {
@@ -669,9 +855,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
.and(w -> w.isNull(SmHotelEntity::getUpstreamStatus)
.or()
.ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"))
.and(w -> w.isNull(SmHotelEntity::getSheetName)
.or()
.eq(SmHotelEntity::getSheetName, SmHotelDataSource.SHANGYOU))
.apply(channelSheetSql(ch), ch)
.orderByAsc(SmHotelEntity::getId)
.last("limit " + batchSize));
if (CollUtil.isEmpty(rows)) {
@@ -773,9 +957,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
int processed = 0;
List<String> failLines = new ArrayList<>();
int intervalMs = TYPE_ROLL.equals(syncType)
? Math.max(0, properties.getPriceRollIntervalMs())
: Math.max(0, properties.getDetailPullIntervalMs());
LocalDate day = rollDay != null ? rollDay : LocalDate.now().plusDays(Math.max(1, properties.getPriceRollDayOffset()));
? Math.max(0, props().getPriceRollIntervalMs())
: Math.max(0, props().getDetailPullIntervalMs());
LocalDate day = rollDay != null ? rollDay : LocalDate.now().plusDays(Math.max(1, props().getPriceRollDayOffset()));
LocalDate end = day.plusDays(1);
progress(logId, 8, "RETRY", "开始重试 " + target + " 家"
@@ -885,7 +1069,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
// fall through
}
}
return LocalDate.now().plusDays(Math.max(1, properties.getPriceRollDayOffset()));
return LocalDate.now().plusDays(Math.max(1, props().getPriceRollDayOffset()));
}
private static void appendFail(List<String> failLines, SmHotelEntity h, String reason) {
@@ -916,8 +1100,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
/** 文档限制:全量房价每天次数 + 最小间隔 */
private String checkFullPriceSyncQuota() {
int maxPerDay = Math.max(1, properties.getFullPriceSyncMaxPerDay());
int minHours = Math.max(0, properties.getFullPriceSyncMinIntervalHours());
int maxPerDay = Math.max(1, props().getFullPriceSyncMaxPerDay());
int minHours = Math.max(0, props().getFullPriceSyncMinIntervalHours());
String last = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_FULL_PRICE_SYNC_LAST);
if (StrUtil.isNotBlank(last) && minHours > 0) {
try {
@@ -1009,7 +1193,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private int markOfflineFromDiff() {
int updated = 0;
int batch = Math.max(50, properties.getDiffScanBatchSize());
int batch = Math.max(50, props().getDiffScanBatchSize());
// 不用 SET.scan:Redisson 与 Spring Data Redis 3.3 的 Cursor 不兼容会抛 AbstractMethodError
Set<String> all = redisTemplate.opsForSet().members(ShangyouHotelApi.REDIS_DIFF_OFFLINE_SET);
if (all == null || all.isEmpty()) {
@@ -1035,7 +1219,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private int reactivateBackOnlineHotels() {
int updated = 0;
long lastId = 0L;
int batch = Math.max(50, properties.getDiffScanBatchSize());
int batch = Math.max(50, props().getDiffScanBatchSize());
while (true) {
List<SmHotelEntity> rows = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getHotelCode)
@@ -1131,12 +1315,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
source = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_IMPORT_DATA_SOURCE);
}
if (StrUtil.isBlank(source)) {
source = StrUtil.trim(properties.getDefaultImportDataSource());
source = StrUtil.trim(props().getDefaultImportDataSource());
}
if (StrUtil.isBlank(source)) {
return R.failed("请选择数据来源后再导入");
}
int max = limit == null || limit <= 0 ? properties.getAutoImportBatchSize() : limit;
int max = limit == null || limit <= 0 ? props().getAutoImportBatchSize() : limit;
max = Math.min(Math.max(max, 1), 5000);
List<Map<String, Object>> batch = new ArrayList<>();
// 不用 SET.scan(Redisson/Spring Data Redis 3.3 Cursor 不兼容)
@@ -1174,6 +1358,17 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return r;
}
/**
* 渠道匹配 SQL:商游兼容历史 sheet_name 为空;其它渠道精确匹配。
* 占位符 {0} 由 Wrapper.apply 传入 channel。
*/
private static String channelSheetSql(String channel) {
if (SmHotelDataSource.SHANGYOU.equalsIgnoreCase(channel)) {
return "(sheet_name is null or sheet_name = '' or lower(sheet_name) = {0})";
}
return "lower(sheet_name) = {0}";
}
/** 人工同步必须显式传;定时任务用配置 defaultImportDataSource */
private String resolveImportDataSource(String dataSource, String triggerType) {
String source = StrUtil.trim(dataSource);
@@ -1181,7 +1376,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return source;
}
if (!"MANUAL".equalsIgnoreCase(StrUtil.trim(triggerType))) {
return StrUtil.trim(properties.getDefaultImportDataSource());
return StrUtil.trim(props().getDefaultImportDataSource());
}
return null;
}
@@ -1189,15 +1384,15 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public Map<String, Object> getSyncSchedule() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("springScheduleEnabled", properties.isSpringScheduleEnabled());
m.put("hotelListDailySyncEnabled", properties.isHotelListDailySyncEnabled());
m.put("hotelListDailyCron", properties.getHotelListDailyCron());
m.put("priceRollDailyEnabled", properties.isPriceRollDailyEnabled());
m.put("priceRollDailyCron", properties.getPriceRollDailyCron());
m.put("localDailyRollEnabled", properties.isLocalDailyRollEnabled());
m.put("localDailyRollCron", properties.getLocalDailyRollCron());
m.put("autoImportNewHotels", properties.isAutoImportNewHotels());
m.put("autoMarkOfflineHotels", properties.isAutoMarkOfflineHotels());
m.put("springScheduleEnabled", props().isSpringScheduleEnabled());
m.put("hotelListDailySyncEnabled", props().isHotelListDailySyncEnabled());
m.put("hotelListDailyCron", props().getHotelListDailyCron());
m.put("priceRollDailyEnabled", props().isPriceRollDailyEnabled());
m.put("priceRollDailyCron", props().getPriceRollDailyCron());
m.put("localDailyRollEnabled", props().isLocalDailyRollEnabled());
m.put("localDailyRollCron", props().getLocalDailyRollCron());
m.put("autoImportNewHotels", props().isAutoImportNewHotels());
m.put("autoMarkOfflineHotels", props().isAutoMarkOfflineHotels());
m.put("hint", "改 Nacos/本地 shangyou-hotel.hotel-list-daily-cron、price-roll-daily-cron、local-daily-roll-cron;"
+ "若用 XXL 请设 spring-schedule-enabled=false,在 XXL 控制台配 Cron(含 smHotelLocalDailyRollJobHandler)");
return m;
@@ -1210,13 +1405,13 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public Map<String, Object> jobSyncHotelList(String triggerType) {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!properties.isHotelListDailySyncEnabled()) {
if (!props().isHotelListDailySyncEnabled()) {
return Map.of("skipped", true, "reason", "hotel-list-daily-sync-enabled=false");
}
R<SmHotelSyncLogEntity> r = startListSyncAsync(properties.isAutoImportNewHotels(),
properties.getDefaultImportDataSource(), StrUtil.blankToDefault(triggerType, "XXL"));
R<SmHotelSyncLogEntity> r = startListSyncAsync(props().isAutoImportNewHotels(),
props().getDefaultImportDataSource(), StrUtil.blankToDefault(triggerType, "XXL"));
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}
@@ -1231,9 +1426,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public Map<String, Object> jobRollDailyPrice(String triggerType) {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!properties.isPriceRollDailyEnabled()) {
if (!props().isPriceRollDailyEnabled()) {
return Map.of("skipped", true, "reason", "price-roll-daily-enabled=false");
}
R<SmHotelSyncLogEntity> r = startPriceRollAsync(StrUtil.blankToDefault(triggerType, "XXL"));
@@ -1251,9 +1446,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public Map<String, Object> jobRollLocalDaily(String triggerType) {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!properties.isLocalDailyRollEnabled()) {
if (!props().isLocalDailyRollEnabled()) {
return Map.of("skipped", true, "reason", "local-daily-roll-enabled=false");
}
R<SmHotelSyncLogEntity> r = startLocalDailyRollAsync(StrUtil.blankToDefault(triggerType, "XXL"));
@@ -0,0 +1,96 @@
package com.bsd.pjcloud.supermarket.util;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.bsd.pjcloud.supermarket.dto.SmHotelDownstreamChannelConfigDTO;
import com.bsd.pjcloud.supermarket.dto.SmHotelShangyouChannelConfigDTO;
import org.yaml.snakeyaml.Yaml;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 渠道对接配置正文解析:支持 YAML(Nacos 风格,含 # 注释)与 JSON(含 // 注释)。
* YAML 键名兼容 kebab-case(base-url)与 camelCase(baseUrl)。
*/
public final class ChannelConfigTextParser {
private ChannelConfigTextParser() {
}
public static SmHotelShangyouChannelConfigDTO parseToShangyouDto(String raw) {
Object normalized = parseToNormalizedObject(raw);
if (normalized == null) {
return null;
}
return JSON.parseObject(JSON.toJSONString(normalized), SmHotelShangyouChannelConfigDTO.class);
}
public static SmHotelDownstreamChannelConfigDTO parseToDownstreamDto(String raw) {
Object normalized = parseToNormalizedObject(raw);
if (normalized == null) {
return null;
}
return JSON.parseObject(JSON.toJSONString(normalized), SmHotelDownstreamChannelConfigDTO.class);
}
/** 校验能否解析;失败抛异常 */
public static void validate(String raw) {
parseToNormalizedObject(raw);
}
@SuppressWarnings("unchecked")
private static Object parseToNormalizedObject(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
String text = raw.trim();
Object loaded;
if (text.startsWith("{") || text.startsWith("[")) {
loaded = JSON.parse(JsonCommentUtil.strip(text));
}
else {
loaded = new Yaml().load(text);
}
return normalizeKeys(loaded);
}
@SuppressWarnings("unchecked")
private static Object normalizeKeys(Object node) {
if (node instanceof Map<?, ?> map) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<?, ?> e : map.entrySet()) {
if (e.getKey() == null) {
continue;
}
String key = kebabToCamel(String.valueOf(e.getKey()));
out.put(key, normalizeKeys(e.getValue()));
}
return out;
}
if (node instanceof List<?> list) {
return list.stream().map(ChannelConfigTextParser::normalizeKeys).toList();
}
return node;
}
/** base-url / spring-schedule-enabled → camelCase */
static String kebabToCamel(String key) {
if (key == null || key.isEmpty() || !key.contains("-")) {
return key;
}
StringBuilder sb = new StringBuilder(key.length());
boolean upper = false;
for (int i = 0; i < key.length(); i++) {
char c = key.charAt(i);
if (c == '-') {
upper = true;
continue;
}
sb.append(upper ? Character.toUpperCase(c) : c);
upper = false;
}
return sb.toString();
}
}
@@ -0,0 +1,107 @@
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 列表筛选一致):
* 选省/市可覆盖全部下级区县。
*/
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);
if (s.length() > 6) {
return s.substring(0, 6);
}
if (s.length() == 6 && s.endsWith("0000")) {
return s.substring(0, 2);
}
if (s.length() == 6 && s.endsWith("00")) {
return s.substring(0, 4);
}
return s;
}
/**
* 酒店是否落在授权区域范围内。
*
* @param scopeAdcode 映射表配置的区域;null 表示不限区域
* @param scopeName 映射表区域名(名称兜底)
* @param hotelAdcode 酒店 districtAdcode
* @param hotelDistrict 酒店 district 名称
*/
public static boolean matchRegion(Long scopeAdcode, String scopeName, Long hotelAdcode, String hotelDistrict) {
if (scopeAdcode == null) {
return true;
}
if (hotelAdcode != null) {
String prefix = areaAdcodePrefix(scopeAdcode);
return String.valueOf(hotelAdcode).startsWith(prefix);
}
// 酒店未落 adcode:按区域名兜底
String name = StrUtil.trim(scopeName);
if (StrUtil.isBlank(name)) {
return false;
}
String hotelName = StrUtil.trim(hotelDistrict);
if (StrUtil.isBlank(hotelName)) {
return false;
}
if (hotelName.contains(name) || name.contains(hotelName)) {
return true;
}
String shortName = shortenAreaName(name);
return StrUtil.isNotBlank(shortName) && hotelName.contains(shortName);
}
public static String shortenAreaName(String name) {
if (StrUtil.isBlank(name)) {
return "";
}
String n = name.trim();
n = n.replaceAll("(壮族|回族|维吾尔|藏族|彝族|朝鲜族)?自治区$", "");
n = n.replaceAll("(特别行政区|省|市|区|县|旗|盟)$", "");
return n.trim();
}
}
@@ -0,0 +1,70 @@
package com.bsd.pjcloud.supermarket.util;
/**
* 去掉 JSON 中的行注释与块注释(字符串字面量内保留,避免误伤 https 地址)。
* 便于后台渠道对接配置带中文说明保存。
*/
public final class JsonCommentUtil {
private JsonCommentUtil() {
}
public static String strip(String input) {
if (input == null || input.isEmpty()) {
return input;
}
StringBuilder out = new StringBuilder(input.length());
boolean inString = false;
boolean escape = false;
boolean inLineComment = false;
boolean inBlockComment = false;
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
char next = i + 1 < input.length() ? input.charAt(i + 1) : 0;
if (inLineComment) {
if (c == '\n') {
inLineComment = false;
out.append(c);
}
continue;
}
if (inBlockComment) {
if (c == '*' && next == '/') {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
out.append(c);
if (escape) {
escape = false;
}
else if (c == '\\') {
escape = true;
}
else if (c == '"') {
inString = false;
}
continue;
}
if (c == '"') {
inString = true;
out.append(c);
continue;
}
if (c == '/' && next == '/') {
inLineComment = true;
i++;
continue;
}
if (c == '/' && next == '*') {
inBlockComment = true;
i++;
continue;
}
out.append(c);
}
return out.toString();
}
}
@@ -110,18 +110,44 @@ spring:
# # 同步后:已停用但上游又出现 → 自动恢复 ACTIVE
# auto-reactivate-online-hotels: true
#
## ========== 下游销售方推送(《第三方数据同步接口文档》)==========
## 本系统作为中转 A,主动把加价后的酒店/房价推到下游 B(与上面「上游」无关,可单独开关)
## ========== 下游销售方推送(环启等;优先走库表「渠道对接配置」direction=downstream)==========
## 本系统作为 A,主动 POST 到 B 的 /third/party/api/*;可配置多家;部门范围见「渠道部门映射」
## 下列 YAML 仅作无库表时的单下游兼容回落(partner=default)
## 伙伴级限流/拉取参数以 PC「渠道对接配置」YAML 为准(模板见 PC downstream-channel-config.template.yml)
##
## order-pull-cron / order-pull-schedule-enabled = 进程级「时钟」:不能按伙伴各写一条 cron。
## 伙伴 YAML 用 order-pull-join-schedule 决定「这家要不要被时钟扫到」;默认 false=只手动拉。
## 定时作用(可选):对 join-schedule=true 的伙伴用 /order/query 按水位补漏;推送照常独立工作。
#hotel-downstream:
# # 是否启用向下游推送
# enabled: false
# # 下游 B 系统根地址(不含尾斜杠),实际请求 = base-url + /third/party/api/...
# base-url: https://b.example.com
# # B 系统分配给我们的 appKey(请求头/签名用)
# base-url: http://114.55.58.87:8080/prod-api
# app-key: TP_xxx
# # B 系统分配的 appSecret(仅本地算签名,不落报文)
# app-secret: your-secret
# # 推下游 HTTP 超时(毫秒)
# timeout-ms: 30000
# # 兼容字段;实际上游拉取天数以 shangyou-hotel.price-days 为准
# daily-days: 90
# # --- 限流(也可写在伙伴 YAML,库表会覆盖这里)---
# rate-limit-hotel-apartment-per-min: 600 # hotel+apartment / 分钟
# rate-limit-product-daily-per-min: 300 # product+daily / 分钟
# rate-limit-order-query-per-min: 120 # order/query / 分钟
# rate-limit-order-detail-per-min: 600 # order/detail / 分钟
# rate-limit-max-retries: 3 # 命中「过于频繁」最多再试几次
# rate-limit-backoff-ms: 5000 # 首次退避毫秒,之后指数翻倍
# # --- 订单拉取默认值(伙伴 YAML 可覆盖)---
# order-pull-enabled: true # 是否允许拉(手动+定时门槛)
# order-pull-join-schedule: false # 默认不进定时;要自动补漏再在伙伴 YAML 开 true
# order-pull-overlap-minutes: 10
# 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
# push-log-body-max-chars: 2048
# push-log-retain-days: 15 # ≤0 关闭自动清理
# push-log-cleanup-cron: 0 40 4 * * ?