Merge branch 'master' of https://gitea.zianxin.com/zianxin/biz-api-cloud-zax
This commit is contained in:
commit
c0d503f00e
81 files changed
+8185
-386
No files matched your search
+27
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+44
@@ -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
|
||||
*
|
||||
|
||||
+2
-1
@@ -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。
|
||||
*
|
||||
|
||||
+8
-1
@@ -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=",">
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
+105
@@ -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;
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
+78
@@ -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;
|
||||
}
|
||||
+16
-1
@@ -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;
|
||||
|
||||
+61
@@ -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;
|
||||
}
|
||||
+5
-1
@@ -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;
|
||||
|
||||
+117
-3
@@ -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 * * ?";
|
||||
}
|
||||
+33
-1
@@ -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}")
|
||||
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -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");
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+129
@@ -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);
|
||||
}
|
||||
}
|
||||
+127
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
-12
@@ -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());
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
+46
@@ -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();
|
||||
}
|
||||
}
|
||||
+6
@@ -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";
|
||||
}
|
||||
+47
-67
@@ -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);
|
||||
}
|
||||
}
|
||||
+142
-13
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+325
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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() {
|
||||
}
|
||||
}
|
||||
+237
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-15
@@ -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();
|
||||
|
||||
+38
-30
@@ -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;
|
||||
|
||||
+10
-5
@@ -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) {
|
||||
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -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) {
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+19
-1
@@ -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();
|
||||
}
|
||||
+59
@@ -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();
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
+22
@@ -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();
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+17
-4
@@ -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);
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+8
-1
@@ -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)。
|
||||
|
||||
+271
-22
@@ -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 < 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");
|
||||
|
||||
+548
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+439
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
@@ -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();
|
||||
}
|
||||
}
|
||||
+123
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+443
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+600
@@ -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;
|
||||
}
|
||||
}
|
||||
+974
-62
File diff suppressed because it is too large.
Load diff
+468
-67
@@ -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);
|
||||
}
|
||||
}
|
||||
+82
@@ -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)";
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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
|
||||
|
||||
+256
-61
@@ -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"));
|
||||
|
||||
+96
@@ -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();
|
||||
}
|
||||
}
|
||||
+107
@@ -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();
|
||||
}
|
||||
}
|
||||
+70
@@ -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 * * ?
|
||||
|
||||
Reference in new issue
Block a user