酒店业务增强

This commit is contained in:
zjf committed 2026-10-14 20:14:23 +08:00
1 parent c5eba0423f
commit f784f75eb3
51 files changed
+3249 -280

No files matched your search

@@ -107,7 +107,8 @@ public interface CanteenDailyMenuService extends IService<CanteenDailyMenuEntity
/**
* 按硬件订单净销量回填当日线上/线下 sold(不改配额)。
* <p>口径与 hardware {@code listDailyMenuNetSold} 一致:CANTEEN_DAILY_MENU、
* <p>口径与 hardware {@code listDailyMenuNetSold} 一致:
* App 每日菜谱 {@code CANTEEN_DAILY_MENU} + 消费机线下 {@code CANTEEN_SPU}/order_type=1;
* 已支付/已完成,扣已完成售后;orderType=2→onlineSold,1→offlineSold。
* 当日无对应订单的菜谱行 sold 置 0。
*
@@ -1473,6 +1473,10 @@
每日菜谱库存剩余:按用餐日 appt_use_date 统计净销量。
线上=order_type 2,线下=order_type 1;扣硬售后 status=50 的份数;不含 XFJ。
先按父购物车+商品汇总购买量,再扣退款,避免多行购物车重复扣退。
商品来源:
- App 每日菜谱预约:CANTEEN_DAILY_MENU
- 消费机线下购买:CANTEEN_SPU(createCard 默认来源),实时扣减已按当日菜谱分流到 offline_sold
若只认 CANTEEN_DAILY_MENU,「按订单重算」会把消费机已售全部刷成 0。
-->
<select id="listDailyMenuNetSold" resultType="com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO">
select o.appt_use_date as mealDate,
@@ -1511,7 +1515,10 @@
and o.hour_type not in ('-1')
and o.order_status in ('5', '6', '20')
and o.order_type in ('1', '2')
and p.product_source = 'CANTEEN_DAILY_MENU'
and (
p.product_source = 'CANTEEN_DAILY_MENU'
or (o.order_type = '1' and p.product_source = 'CANTEEN_SPU')
)
<if test="dto.hourTypes != null and dto.hourTypes.size() != 0">
and o.hour_type in
<foreach collection="dto.hourTypes" item="v" open="(" close=")" separator=",">
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.supermarket.constant;
/**
* 酒店第三方渠道方向:上游给我们数据,下游我们推数据帮我们卖。
*/
public final class SmHotelChannelDirection {
/** 上游:供应商 → 本平台 */
public static final String UPSTREAM = "upstream";
/** 下游:本平台 → 销售方 */
public static final String DOWNSTREAM = "downstream";
private SmHotelChannelDirection() {
}
public static String normalize(String direction) {
String d = direction == null ? "" : direction.trim().toLowerCase();
if (DOWNSTREAM.equals(d)) {
return DOWNSTREAM;
}
return UPSTREAM;
}
public static boolean isDownstream(String direction) {
return DOWNSTREAM.equals(normalize(direction));
}
public static boolean isUpstream(String direction) {
return !isDownstream(direction);
}
}
@@ -0,0 +1,54 @@
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";
/** @deprecated 历史误用名,normalize 后等价 {@link #HUANQI} */
@Deprecated
public static final String XIAYOU = "xiayou";
private SmHotelDownstreamProtocol() {
}
public static String normalize(String protocol) {
String p = protocol == null ? "" : protocol.trim().toLowerCase();
if (p.isEmpty() || XIAYOU.equals(p)) {
return HUANQI;
}
return p;
}
/** 当前已注册可配置的协议(后台下拉) */
public static Set<String> supported() {
Set<String> set = new LinkedHashSet<>();
set.add(HUANQI);
return set;
}
public static boolean isSupported(String protocol) {
return supported().contains(normalize(protocol));
}
public static String label(String protocol) {
String p = normalize(protocol);
if (HUANQI.equals(p)) {
return "环启";
}
return p;
}
public static String blankToDefault(String protocol) {
return normalize(StrUtil.blankToDefault(StrUtil.trim(protocol), HUANQI));
}
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.supermarket.constant;
/**
* 下游推送授权方式。
*/
public final class SmHotelDownstreamScopeType {
/** 按部门(+ 可选位置区域) */
public static final String DEPT = "dept";
/** 按指定酒店 */
public static final String HOTEL = "hotel";
private SmHotelDownstreamScopeType() {
}
public static String normalize(String scopeType) {
String s = scopeType == null ? "" : scopeType.trim().toLowerCase();
if (HOTEL.equals(s)) {
return HOTEL;
}
return DEPT;
}
public static boolean isHotel(String scopeType) {
return HOTEL.equals(normalize(scopeType));
}
public static boolean isDept(String scopeType) {
return !isHotel(scopeType);
}
}
@@ -0,0 +1,31 @@
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")
private String appKey;
@Schema(description = "B 系统分配的 appSecret(仅本地签名)")
private String appSecret;
@Schema(description = "HTTP 超时毫秒")
private Integer timeoutMs;
@Schema(description = "推送日历天数(预留)")
private Integer dailyDays;
}
@@ -0,0 +1,105 @@
package com.bsd.pjcloud.supermarket.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 商游(飞猪链路)对接参数:从渠道 config 正文(YAML/JSON)反序列化。
* 键名兼容 kebab-case 与 camelCase;未填字段回落 YAML/Nacos {@code shangyou-hotel.*}。
*/
@Data
@Schema(description = "商游渠道对接参数")
public class SmHotelShangyouChannelConfigDTO {
@Schema(description = "对方根地址,如 https://xxx/link/provider")
private String baseUrl;
@Schema(description = "鉴权根地址;空则用 baseUrl")
private String authBaseUrl;
@Schema(description = "业务根地址;空则用 baseUrl")
private String bizBaseUrl;
private String clientId;
private String clientSecret;
private String cardId;
private String cardPassword;
private String callbackSecret;
private String bookerId;
private Boolean orderIsGroup;
private String orderGuaranteeMode;
private String roomPricePath;
private String memberLevels;
private Integer timeoutMs;
private Integer tokenSkewSeconds;
private Integer priceDays;
private Integer hotelListCacheMinutes;
private Integer hotelListWarnSize;
private Long callbackTenantId;
private Boolean callbackQueueEnabled;
private Integer callbackMaxAttempts;
private Integer callbackTaskIntervalMs;
private Double priceApiQps;
private Integer appPayTimeoutMinutes;
/** 总开关:false 则本服务 Spring Cron 日更全部跳过(测试环境可关) */
private Boolean springScheduleEnabled;
private Boolean hotelListDailySyncEnabled;
private String hotelListDailyCron;
private Boolean priceRollDailyEnabled;
private String priceRollDailyCron;
private Boolean localDailyRollEnabled;
private String localDailyRollCron;
private Boolean autoImportNewHotels;
private Integer autoImportBatchSize;
private Integer priceRollDayOffset;
private Integer priceRollBatchSize;
private Integer priceRollIntervalMs;
private Integer fullPriceSyncMaxPerDay;
private Integer fullPriceSyncMinIntervalHours;
private Integer detailPullIntervalMs;
private Integer detailPullMaxHotels;
private Integer diffScanBatchSize;
private Boolean autoMarkOfflineHotels;
private Boolean autoReactivateOnlineHotels;
private String defaultImportDataSource;
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_channel_config")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店第三方渠道对接配置")
public class SmHotelChannelConfigEntity extends Model<SmHotelChannelConfigEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "渠道标识(上游对齐字典;下游为伙伴编码)")
private String channel;
@Schema(description = "upstream上游 / downstream下游")
private String direction;
@Schema(description = "对接协议(下游必填,如 huanqi=环启;上游可空)")
private String protocol;
@Schema(description = "1启用 0停用")
private String enabled;
@Schema(description = "对接参数正文(YAML 或 JSON,可含注释)")
private String configJson;
private String remark;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -0,0 +1,81 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_channel_dept")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店第三方渠道部门映射")
public class SmHotelChannelDeptEntity extends Model<SmHotelChannelDeptEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "渠道/下游伙伴标识(上游对齐字典;下游为伙伴编码)")
private String channel;
@Schema(description = "upstream上游 / downstream下游")
private String direction;
@Schema(description = "下游授权方式:dept按部门 / hotel按指定酒店(上游固定 dept)")
private String scopeType;
@Schema(description = "部门 ID(上游=归属部门;下游 dept 模式=允许推送的部门)")
private Long deptId;
@Schema(description = "位置区域 adcode(下游 dept 模式可选;空=该部门全部区域)")
private Long districtAdcode;
@Schema(description = "位置区域名称(展示/名称兜底)")
private String district;
@Schema(description = "指定酒店 ID,逗号分隔(下游 hotel 模式)")
private String hotelIds;
@Schema(description = "备注")
private String remark;
@Schema(description = "1启用 0停用")
private String status;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
/** 列表展示:部门名称(非表字段) */
@TableField(exist = false)
@Schema(description = "部门名称")
private String deptName;
/** 列表展示:渠道字典标签(非表字段) */
@TableField(exist = false)
@Schema(description = "渠道名称")
private String channelLabel;
/** 列表展示:指定酒店摘要(非表字段) */
@TableField(exist = false)
@Schema(description = "指定酒店摘要")
private String hotelNames;
}
@@ -0,0 +1,75 @@
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")
private Long hotelId;
@Schema(description = "外部酒店ID")
private String extHotelId;
@Schema(description = "动作:hotel_sync/apartment_sync/product_sync/daily_sync")
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失败")
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;
}
@@ -124,7 +124,7 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@Schema(description = "下单来源")
private String orderSource;
/** 列表展示:酒店数据来源 local/shangyou(非表字段) */
/** 列表展示:酒店数据来源 local/shangyou/其它渠道(非表字段) */
@TableField(exist = false)
@Schema(description = "酒店数据来源")
private String hotelDataSource;
@@ -0,0 +1,61 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("canteen_hotel_order_upstream_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店订单上游调用流水")
public class SmHotelOrderUpstreamLogEntity extends Model<SmHotelOrderUpstreamLogEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "订单 ID")
private Long orderId;
@Schema(description = "渠道 local/shangyou/...")
private String channel;
@Schema(description = "CREATE/QUERY/CANCEL/RETRY_ADD/AUTO_REFUND/CHECK_AVAIL")
private String action;
@Schema(description = "1成功 0失败")
private String success;
private String bizCode;
private String message;
private String reqJson;
private String respJson;
private Long costMs;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.vo;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -40,6 +41,9 @@ public class SmHotelOrderDetailVo {
@Schema(description = "上游响应快照(解析自 upstreamRespJson)")
private Object upstreamResp;
@Schema(description = "上游调用流水(CREATE/QUERY/CANCEL/AUTO_REFUND 等)")
private List<SmHotelOrderUpstreamLogEntity> upstreamLogs;
@Data
@Schema(description = "酒店摘要")
public static class HotelBrief {
@@ -52,7 +56,7 @@ public class SmHotelOrderDetailVo {
private String groupCode;
/** local / shangyou */
/** 数据来源:local / shangyou / 其它渠道 sheet_name */
private String dataSource;
private String upstreamStatus;
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Component;
/**
* 下游销售方推送配置(本系统作为 A,主动推送到 B 的 /third/party/api/*)。
* 优先读库表「渠道对接配置」direction=downstream 的 YAML;本类作 YAML/Nacos 单下游兼容回落。
*/
@Data
@Component
@@ -15,6 +16,12 @@ 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 = "";
@@ -0,0 +1,108 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelChannelConfig")
@Tag(name = "酒店第三方渠道对接配置(YAML/JSON 热更新,区分上下游)")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelChannelConfigController {
private final SmHotelChannelConfigService channelConfigService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<Page<SmHotelChannelConfigEntity>> page(@ParameterObject Page<SmHotelChannelConfigEntity> page,
@RequestParam(required = false) String channel,
@RequestParam(required = false) String direction) {
return R.ok(channelConfigService.pageConfigs(page, channel, direction));
}
@Operation(summary = "按渠道+方向查询")
@GetMapping("/byChannel")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<SmHotelChannelConfigEntity> byChannel(@RequestParam String channel,
@RequestParam(required = false) String direction) {
return R.ok(channelConfigService.getByChannel(channel, direction));
}
@Operation(summary = "上游 YAML 默认值(表单占位对照)")
@GetMapping("/yamlDefaults")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<ShangyouHotelProperties> yamlDefaults() {
return R.ok(channelConfigService.yamlDefaultsSnapshot());
}
@Operation(summary = "下游 YAML 默认值(表单占位对照)")
@GetMapping("/downstreamYamlDefaults")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<HotelDownstreamProperties> downstreamYamlDefaults() {
return R.ok(channelConfigService.downstreamYamlDefaultsSnapshot());
}
@Operation(summary = "已实现的下游协议列表")
@GetMapping("/downstreamProtocols")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<java.util.List<String>> downstreamProtocols() {
return R.ok(channelConfigService.listDownstreamProtocols());
}
@Operation(summary = "已配置的下游伙伴列表(范围映射下拉)")
@GetMapping("/downstreamPartners")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<java.util.List<SmHotelChannelConfigEntity>> downstreamPartners() {
return R.ok(channelConfigService.listDownstreamPartners());
}
@Operation(summary = "当前生效的商游运行时配置(库表覆盖后)")
@GetMapping("/shangyouRuntime")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_view')")
public R<ShangyouHotelProperties> shangyouRuntime() {
return R.ok(channelConfigService.resolveShangyouRuntime());
}
@SysLog("保存酒店渠道对接配置")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> save(@RequestBody SmHotelChannelConfigEntity entity) {
return channelConfigService.saveOrUpdateByChannel(entity);
}
@SysLog("修改酒店渠道对接配置")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> update(@RequestBody SmHotelChannelConfigEntity entity) {
return channelConfigService.saveOrUpdateByChannel(entity);
}
@SysLog("删除酒店渠道对接配置")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> remove(@RequestBody Long[] ids) {
return channelConfigService.removeByIdsSafe(ids);
}
@SysLog("刷新渠道对接配置缓存")
@PostMapping("/evictCache")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelConfig_edit')")
public R<Boolean> evictCache(@RequestParam(required = false) String channel) {
channelConfigService.evictCache(channel);
return R.ok(Boolean.TRUE);
}
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelChannelDept")
@Tag(name = "酒店第三方渠道部门映射")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelChannelDeptController {
private final SmHotelChannelDeptService channelDeptService;
@Operation(summary = "分页")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_view')")
public R<Page<SmHotelChannelDeptEntity>> page(@ParameterObject Page<SmHotelChannelDeptEntity> page,
@ParameterObject SmHotelChannelDeptEntity query) {
return R.ok(channelDeptService.pageMappings(page, query));
}
@Operation(summary = "启用中的渠道部门(同步/拉取下拉)")
@GetMapping("/listEnabled")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync') || @pms.hasPermission('supermarket_smHotelChannelDept_view') || @pms.hasPermission('supermarket_smHotel_add')")
public R<List<SmHotelChannelDeptEntity>> listEnabled() {
return R.ok(channelDeptService.listEnabled());
}
@SysLog("新增酒店渠道部门映射")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_add')")
public R<Boolean> save(@RequestBody SmHotelChannelDeptEntity entity) {
return channelDeptService.saveMapping(entity);
}
@SysLog("修改酒店渠道部门映射")
@PutMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_edit')")
public R<Boolean> update(@RequestBody SmHotelChannelDeptEntity entity) {
return channelDeptService.updateMapping(entity);
}
@SysLog("删除酒店渠道部门映射")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotelChannelDept_del')")
public R<Boolean> remove(@RequestBody Long[] ids) {
return channelDeptService.removeMappings(ids);
}
}
@@ -0,0 +1,39 @@
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.supermarket.entity.SmHotelDownstreamPushLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
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("/smHotelDownstreamPushLog")
@Tag(name = "酒店下游推送流水")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class SmHotelDownstreamPushLogController {
private final SmHotelDownstreamPushLogService pushLogService;
@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));
}
}
@@ -298,12 +298,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 +312,7 @@ public class SmHotelMidController {
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("dataSource", dataSource);
data.put("message", "已提交,请轮询 /mid/syncLog/{id} 或打开同步日志");
return R.ok(data, started.getMsg());
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.supermarket.event;
import lombok.Getter;
/**
* 上游硬失败后、事务提交再触发原路退款(避免嵌在支付回调长事务内)。
*/
@Getter
public class HotelUpstreamFailRefundEvent {
private final Long orderId;
private final Long tenantId;
private final String reason;
public HotelUpstreamFailRefundEvent(Long orderId, Long tenantId, String reason) {
this.orderId = orderId;
this.tenantId = tenantId;
this.reason = reason;
}
}
@@ -0,0 +1,77 @@
package com.bsd.pjcloud.supermarket.event;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 支付后上游硬失败:提交后自动原路退款,并写 AUTO_REFUND 流水。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotelUpstreamFailRefundListener {
private final SmHotelOrderService orderService;
private final SmHotelOrderMapper orderMapper;
private final SmHotelOrderUpstreamLogService upstreamLogService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onUpstreamFail(HotelUpstreamFailRefundEvent event) {
if (event == null || event.getOrderId() == null) {
return;
}
Long tenantId = event.getTenantId() != null ? event.getTenantId() : TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = 1L;
}
Long tid = tenantId;
try {
TenantBroker.runAs(tid, id -> doRefund(event));
}
catch (Exception e) {
log.error("|酒店自动退款|异常|orderId={}|", event.getOrderId(), e);
markRefundNeedManual(event.getOrderId(), "自动退款异常: " + e.getMessage());
}
}
private void doRefund(HotelUpstreamFailRefundEvent event) {
String reason = StrUtil.blankToDefault(event.getReason(), "上游下单失败自动退款");
long t0 = System.currentTimeMillis();
R<Boolean> res = orderService.refundByAdmin(event.getOrderId(), reason);
long cost = System.currentTimeMillis() - t0;
boolean ok = res != null && res.isOk();
upstreamLogService.saveSimple(event.getOrderId(), "auto", "AUTO_REFUND", ok,
ok ? "OK" : "FAIL", ok ? "自动退款已受理" : (res == null ? "退款无响应" : res.getMsg()),
null, null, cost);
if (!ok) {
markRefundNeedManual(event.getOrderId(),
"上游失败且自动退款失败: " + (res == null ? "无响应" : StrUtil.blankToDefault(res.getMsg(), "未知")));
}
log.info("|酒店自动退款|完成|orderId={}|ok={}|msg={}|", event.getOrderId(), ok,
res == null ? null : res.getMsg());
}
private void markRefundNeedManual(Long orderId, String msg) {
SmHotelOrderEntity order = orderMapper.selectById(orderId);
if (order == null) {
return;
}
order.setCompensateStatus("NEED_MANUAL");
order.setFailMsg(StrUtil.sub(StrUtil.blankToDefault(order.getFailMsg(), "") + ";" + msg, 0, 500));
order.setLastCompensateMsg(StrUtil.sub(msg, 0, 500));
orderMapper.updateById(order);
}
}
@@ -0,0 +1,27 @@
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);
}
@@ -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();
}
}
@@ -1,97 +1,55 @@
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);
}
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);
}
}
@@ -0,0 +1,155 @@
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.DownstreamSignHelper;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 环启下游协议:本系统作为 A,POST B 的 /third/party/api/* + MD5 签名。
* 文档见 docs/xiayou《第三方数据同步接口文档》。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HuanqiDownstreamHotelAdapter implements DownstreamHotelAdapter {
private static final String TAG = "下游推送[环启]";
private final SmHotelDownstreamPushLogService pushLogService;
@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);
}
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 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);
}
JSONObject json = JSONUtil.parseObj(res);
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(partnerCode, hotelId, extHotelId, action, url, bodyJson, res, httpStatus, bizCode, ok, errMsg,
System.currentTimeMillis() - start);
}
}
private void writeLog(String partnerCode, Long hotelId, String extHotelId, String action, String url,
String requestBody, String responseBody, Integer httpStatus, Integer bizCode, boolean ok, String errMsg,
long costMs) {
SmHotelDownstreamPushLogEntity row = new SmHotelDownstreamPushLogEntity();
row.setPartnerCode(partnerCode);
row.setHotelId(hotelId);
row.setExtHotelId(extHotelId);
row.setAction(action);
row.setRequestUrl(url);
row.setRequestBody(requestBody);
row.setResponseBody(responseBody);
row.setHttpStatus(httpStatus);
row.setBizCode(bizCode);
row.setSuccess(ok ? "1" : "0");
row.setErrorMsg(StrUtil.maxLength(errMsg, 900));
row.setCostMs(costMs);
pushLogService.record(row);
}
private static String trimSlash(String base) {
if (base == null) {
return "";
}
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
}
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import com.alibaba.fastjson.JSONObject;
/**
* 酒店上游订单网关(按数据来源渠道插拔)。
* <p>
* 新增第三方:实现本接口并注册为 Spring Bean,字典 {@code sm_hotel_data_source} 增加对应 item_value 即可。
*/
public interface HotelUpstreamOrderGateway {
/** 渠道标识,与 hotel.sheet_name / 字典值一致,如 shangyou */
String channel();
/** 可定校验,返回上游 content(渠道自定义结构) */
JSONObject checkAvailability(JSONObject body);
/** 创建上游订单 */
HotelUpstreamOrderResult createOrder(JSONObject body);
/** 按下游 requestNo / channelNo 查询 */
HotelUpstreamOrderResult queryByRequestNo(String requestNo);
/** 按上游平台单号查询 */
HotelUpstreamOrderResult queryOrder(String platformNo, String hotelCode);
/** 取消上游订单 */
HotelUpstreamOrderResult cancelOrder(String platformNo, String hotelCode);
}
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 按酒店数据来源(sheet_name)解析上游订单网关。
* <ul>
* <li>local:无上游网关</li>
* <li>空白历史:兼容映射到商游</li>
* <li>已注册渠道:返回对应 Gateway</li>
* <li>未注册渠道:{@link #isUnsupportedChannel} 为 true,履约应硬失败</li>
* </ul>
*/
@Component
public class HotelUpstreamOrderGatewayRegistry {
private final Map<String, HotelUpstreamOrderGateway> byChannel = new HashMap<>();
public HotelUpstreamOrderGatewayRegistry(List<HotelUpstreamOrderGateway> gateways) {
if (gateways != null) {
for (HotelUpstreamOrderGateway g : gateways) {
if (g == null || StrUtil.isBlank(g.channel())) {
continue;
}
byChannel.put(SmHotelDataSource.normalize(g.channel()).toLowerCase(), g);
}
}
}
/** 落库/展示用的渠道键:local / shangyou / 其它 sheet_name */
public String resolveChannelKey(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return SmHotelDataSource.LOCAL;
}
String ds = SmHotelDataSource.normalize(hotelSheetName);
if (ds.isEmpty()) {
return SmHotelDataSource.SHANGYOU;
}
return ds.toLowerCase();
}
/**
* 查找网关。本地返回 empty;空白历史按商游;未注册渠道返回 empty(配合 {@link #isUnsupportedChannel})。
*/
public Optional<HotelUpstreamOrderGateway> find(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return Optional.empty();
}
String key = resolveChannelKey(hotelSheetName);
return Optional.ofNullable(byChannel.get(key.toLowerCase()));
}
/** 非本地且未注册实现的渠道 */
public boolean isUnsupportedChannel(String hotelSheetName) {
if (SmHotelDataSource.isLocal(hotelSheetName)) {
return false;
}
String key = resolveChannelKey(hotelSheetName);
return !byChannel.containsKey(key.toLowerCase());
}
}
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
import lombok.Data;
/**
* 上游订单调用的统一结果(渠道无关语义,供履约/补偿/流水复用)。
*/
@Data
public class HotelUpstreamOrderResult {
/** HTTP/网络超时、5xx 等,未拿到确定业务结果 */
private boolean uncertain;
/** 业务码,如 200 / 3028 */
private String code;
private String message;
/** content 对象(orderId/bookStatus 等) */
private JSONObject content;
private JSONObject raw;
private Integer httpStatus;
private String exceptionMsg;
public boolean isBizOk() {
return !uncertain && ("200".equals(code) || "0".equals(code) || "OK".equalsIgnoreCase(code));
}
/** 总对总付款失败,文档要求走补偿 */
public boolean isPayFail3028() {
return "3028".equals(code);
}
public String orderId() {
return content == null ? null : content.getString("orderId");
}
public String bookStatus() {
return content == null ? null : content.getString("bookStatus");
}
public String payStatus() {
return content == null ? null : content.getString("payStatus");
}
public static HotelUpstreamOrderResult uncertain(String msg, Integer httpStatus) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(true);
r.setMessage(msg);
r.setHttpStatus(httpStatus);
r.setExceptionMsg(msg);
return r;
}
public static HotelUpstreamOrderResult fail(String code, String message) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode(code);
r.setMessage(message);
return r;
}
/** 兼容现有商游客户端结果 */
public static HotelUpstreamOrderResult fromShangyou(ShangyouOrderCallResult src) {
if (src == null) {
return uncertain("上游无响应", null);
}
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(src.isUncertain());
r.setCode(src.getCode());
r.setMessage(src.getMessage());
r.setContent(src.getContent());
r.setRaw(src.getRaw());
r.setHttpStatus(src.getHttpStatus());
r.setExceptionMsg(src.getExceptionMsg());
return r;
}
public static HotelUpstreamOrderResult ofCancel(boolean ok, String message) {
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode(ok ? "200" : "FAIL");
r.setMessage(StrUtil.blankToDefault(message, ok ? "ok" : "cancel failed"));
JSONObject content = new JSONObject();
content.put("cancelled", ok);
r.setContent(content);
return r;
}
}
@@ -0,0 +1,64 @@
package com.bsd.pjcloud.supermarket.integration.hotel.upstream;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 商游(玉编电云)上游订单网关。
*/
@Component
@RequiredArgsConstructor
public class ShangyouHotelUpstreamGateway implements HotelUpstreamOrderGateway {
private final ShangyouHotelClient client;
@Override
public String channel() {
return SmHotelDataSource.SHANGYOU;
}
@Override
public JSONObject checkAvailability(JSONObject body) {
return client.checkAvailability(body);
}
@Override
public HotelUpstreamOrderResult createOrder(JSONObject body) {
return HotelUpstreamOrderResult.fromShangyou(client.createOrderRaw(body));
}
@Override
public HotelUpstreamOrderResult queryByRequestNo(String requestNo) {
return HotelUpstreamOrderResult.fromShangyou(client.queryByRequestNoRaw(requestNo));
}
@Override
public HotelUpstreamOrderResult queryOrder(String platformNo, String hotelCode) {
try {
JSONObject content = client.queryOrder(platformNo, hotelCode);
HotelUpstreamOrderResult r = new HotelUpstreamOrderResult();
r.setUncertain(false);
r.setCode("200");
r.setContent(content);
r.setRaw(content);
return r;
}
catch (Exception e) {
return HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
}
@Override
public HotelUpstreamOrderResult cancelOrder(String platformNo, String hotelCode) {
try {
boolean ok = client.cancelOrder(platformNo, hotelCode);
return HotelUpstreamOrderResult.ofCancel(ok, ok ? "取消成功" : "上游取消返回失败");
}
catch (Exception e) {
return HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
}
}
@@ -8,6 +8,7 @@ import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -30,28 +31,35 @@ import org.springframework.data.redis.core.ZSetOperations;
/**
* 玉编电云 CRS 客户端:Basic 换 Token,业务请求 Bearer + cardId/cardPassWord。
* <p>
* 运行时参数优先读库表渠道对接配置(热更新),未配置时回落 YAML/Nacos。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ShangyouHotelClient {
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final StringRedisTemplate redisTemplate;
/** 库表覆盖后的生效配置,保存后台对接配置后立即生效 */
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
public boolean isEnabled() {
return properties.isEnabled();
return props().isEnabled();
}
public void ensureEnabled() {
if (!properties.isEnabled()) {
throw new CheckedException("上游酒店未启用,请配置 shangyou-hotel.enabled=true");
if (!props().isEnabled()) {
throw new CheckedException("上游酒店未启用:请在「渠道对接配置」启用商游,或配置 shangyou-hotel.enabled=true");
}
if (StrUtil.hasBlank(properties.resolveAuthBaseUrl(), properties.resolveBizBaseUrl(), properties.getClientId(),
properties.getClientSecret(), properties.getCardId(), properties.getCardPassword())) {
if (StrUtil.hasBlank(props().resolveAuthBaseUrl(), props().resolveBizBaseUrl(), props().getClientId(),
props().getClientSecret(), props().getCardId(), props().getCardPassword())) {
throw new CheckedException(
"上游酒店配置不完整:至少配置 base-url(或 auth-base-url+biz-base-url)以及 clientId/secret、cardId/password");
"上游酒店配置不完整:请在「渠道对接配置」填写 baseUrl/clientId/secret/cardId/password,或配置 YAML 默认值");
}
}
@@ -105,7 +113,7 @@ public class ShangyouHotelClient {
if (StrUtil.isNotBlank(reused)) {
return reused;
}
long lockTtlSec = Math.max(45L, properties.getTimeoutMs() / 1000L + 15L);
long lockTtlSec = Math.max(45L, props().getTimeoutMs() / 1000L + 15L);
for (int attempt = 0; attempt < 4; attempt++) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(ShangyouHotelApi.REDIS_TOKEN_LOCK, "1", lockTtlSec, TimeUnit.SECONDS);
@@ -217,9 +225,9 @@ public class ShangyouHotelClient {
}
private String fetchAndCacheToken() {
String url = properties.resolveAuthBaseUrl() + ShangyouHotelApi.OAUTH_TOKEN;
String url = props().resolveAuthBaseUrl() + ShangyouHotelApi.OAUTH_TOKEN;
String basic = Base64.getEncoder()
.encodeToString((properties.getClientId() + ":" + properties.getClientSecret())
.encodeToString((props().getClientId() + ":" + props().getClientSecret())
.getBytes(StandardCharsets.UTF_8));
log.info("shangyou oauth token url={}", url);
try (HttpResponse response = HttpRequest.post(url)
@@ -228,7 +236,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=utf-8")
.header("Connection", "Keep-Alive")
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String body = response.body();
if (!response.isOk()) {
@@ -238,14 +246,14 @@ public class ShangyouHotelClient {
String token = firstNonBlank(resp.getString("access_token"), resp.getString("accessToken"));
if (StrUtil.isBlank(token)) {
throw new CheckedException("上游鉴权未返回 access_token: " + body + "(请核对 auth 地址是否为 "
+ properties.resolveAuthBaseUrl() + "/oauth/token )");
+ props().resolveAuthBaseUrl() + "/oauth/token )");
}
long expires = resp.getLongValue("expires_in");
if (expires <= 0) {
expires = 21600L;
}
// 文档建议缓存约 5 小时 58 分:expires_in(21600) - skew(默认120秒)
long ttl = Math.max(60L, expires - properties.getTokenSkewSeconds());
long ttl = Math.max(60L, expires - props().getTokenSkewSeconds());
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_TOKEN_KEY, token, ttl, TimeUnit.SECONDS);
redisTemplate.opsForValue()
.set(ShangyouHotelApi.REDIS_TOKEN_REFRESHED_AT, String.valueOf(System.currentTimeMillis()), ttl,
@@ -400,9 +408,9 @@ public class ShangyouHotelClient {
JSONArray arr = asArray(resp.get("content"));
resp.remove("content");
int count = arr.size();
if (count >= properties.getHotelListWarnSize()) {
if (count >= props().getHotelListWarnSize()) {
log.warn("|上游酒店列表|数量较大|count={}|warnSize={}|同步期会有短时内存峰值,建议低峰强制刷新|", count,
properties.getHotelListWarnSize());
props().getHotelListWarnSize());
}
String tmpHash = ShangyouHotelApi.REDIS_HOTEL_HASH_KEY + ":tmp";
String tmpZset = ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY + ":tmp";
@@ -446,7 +454,7 @@ public class ShangyouHotelClient {
redisTemplate.rename(tmpHash, ShangyouHotelApi.REDIS_HOTEL_HASH_KEY);
redisTemplate.rename(tmpZset, ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY);
redisTemplate.rename(tmpSet, ShangyouHotelApi.REDIS_UPSTREAM_ID_SET);
long ttl = properties.getHotelListCacheMinutes();
long ttl = props().getHotelListCacheMinutes();
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_HASH_KEY, ttl, TimeUnit.MINUTES);
redisTemplate.expire(ShangyouHotelApi.REDIS_HOTEL_ZSET_KEY, ttl, TimeUnit.MINUTES);
// ID 集合给差集用,TTL 略长,避免日更空窗
@@ -458,7 +466,7 @@ public class ShangyouHotelClient {
redisTemplate.delete(tmpSet);
}
log.info("|上游酒店列表|同步完成|count={}|costMs={}|cacheMinutes={}|store=ZSET+HASH+SET|", count,
System.currentTimeMillis() - t0, properties.getHotelListCacheMinutes());
System.currentTimeMillis() - t0, props().getHotelListCacheMinutes());
}
catch (Exception e) {
redisTemplate.delete(tmpHash);
@@ -529,7 +537,7 @@ public class ShangyouHotelClient {
if (!levels.isEmpty()) {
body.put("memberLevelList", levels);
}
String path = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
String path = StrUtil.blankToDefault(props().getRoomPricePath(), "/hotel/getRoomPrice");
if (!path.startsWith("/")) {
path = "/" + path;
}
@@ -541,7 +549,7 @@ public class ShangyouHotelClient {
* 对方约定约 20 QPS;配置 price-api-qps≤0 关闭。
*/
private void acquirePriceApiPermit() {
double qps = properties.getPriceApiQps();
double qps = props().getPriceApiQps();
if (qps <= 0) {
return;
}
@@ -581,7 +589,7 @@ public class ShangyouHotelClient {
if (StrUtil.isBlank(path)) {
return false;
}
String configured = StrUtil.blankToDefault(properties.getRoomPricePath(), "/hotel/getRoomPrice");
String configured = StrUtil.blankToDefault(props().getRoomPricePath(), "/hotel/getRoomPrice");
String p = path.startsWith("/") ? path : "/" + path;
String c = configured.startsWith("/") ? configured : "/" + configured;
return p.equalsIgnoreCase(c) || p.toLowerCase().contains("getroomprice");
@@ -613,7 +621,7 @@ public class ShangyouHotelClient {
public ShangyouOrderCallResult createOrderRaw(JSONObject body) {
fillCard(body);
if (StrUtil.isBlank(body.getString("bookerId"))) {
body.put("bookerId", StrUtil.blankToDefault(properties.getBookerId(), properties.getCardId()));
body.put("bookerId", StrUtil.blankToDefault(props().getBookerId(), props().getCardId()));
}
return postOrderBiz(ShangyouHotelApi.ORDER_ADD, body);
}
@@ -689,7 +697,7 @@ public class ShangyouHotelClient {
catch (Exception e) {
return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null);
}
String url = properties.resolveBizBaseUrl() + path;
String url = props().resolveBizBaseUrl() + path;
log.info("shangyou order request path={} retried={} bodyKeys={}", path, retried, body.keySet());
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
@@ -697,7 +705,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String respBody = response.body();
int status = response.getStatus();
@@ -746,7 +754,7 @@ public class ShangyouHotelClient {
}
public String resolveCallbackSecret() {
return StrUtil.blankToDefault(properties.getCallbackSecret(), properties.getCardPassword());
return StrUtil.blankToDefault(props().getCallbackSecret(), props().getCardPassword());
}
private JSONObject baseCardBody() {
@@ -756,15 +764,15 @@ public class ShangyouHotelClient {
}
private void fillCard(JSONObject body) {
body.put("cardId", properties.getCardId());
body.put("cardPassWord", properties.getCardPassword());
body.put("cardId", props().getCardId());
body.put("cardPassWord", props().getCardPassword());
}
private List<String> parseMemberLevels() {
if (StrUtil.isBlank(properties.getMemberLevels())) {
if (StrUtil.isBlank(props().getMemberLevels())) {
return Collections.emptyList();
}
return StrUtil.splitTrim(properties.getMemberLevels(), ',');
return StrUtil.splitTrim(props().getMemberLevels(), ',');
}
private JSONObject postBiz(String path, JSONObject body) {
@@ -785,7 +793,7 @@ public class ShangyouHotelClient {
acquirePriceApiPermit();
}
String token = getAccessToken(forceToken, invalidToken);
String url = properties.resolveBizBaseUrl() + path;
String url = props().resolveBizBaseUrl() + path;
log.info("shangyou request path={} retryRound={} forceToken={} bodyKeys={}", path, retryRound, forceToken,
body.keySet());
try (HttpResponse response = HttpRequest.post(url)
@@ -794,7 +802,7 @@ public class ShangyouHotelClient {
.header("Accept-Encoding", "gzip, deflate")
.header("Content-Type", "application/json;charset=UTF-8")
.body(body.toJSONString())
.timeout(properties.getTimeoutMs())
.timeout(props().getTimeoutMs())
.execute()) {
String respBody = response.body();
boolean httpUnauthorized = response.getStatus() == 401;
@@ -1,6 +1,8 @@
package com.bsd.pjcloud.supermarket.integration.shangyou;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import jakarta.annotation.PreDestroy;
@@ -14,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
/**
* 订单补偿调度:按文档在下单后第 3/5/10 分钟 queryByRequestNo(必要时重试 order/add)。
* 启用状态读库表热配置,后台开关后无需重启 Worker。
*/
@Slf4j
@Component
@@ -22,6 +25,8 @@ public class ShangyouOrderCompensateWorker implements ApplicationRunner {
private final ShangyouHotelProperties properties;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelOrderService orderService;
private final AtomicBoolean running = new AtomicBoolean(true);
@@ -30,19 +35,19 @@ public class ShangyouOrderCompensateWorker implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
if (!properties.isEnabled()) {
log.info("|订单补偿|上游未启用,Worker 不启动");
return;
}
worker = new Thread(this::loop, "shangyou-order-compensate");
worker.setDaemon(true);
worker.start();
log.info("|订单补偿|Worker 已启动(轮询 next_compensate_time)");
log.info("|订单补偿|Worker 已启动(轮询 next_compensate_time;渠道启用热感知)");
}
private void loop() {
while (running.get()) {
try {
if (!channelConfigService.isChannelEnabled(SmHotelDataSource.SHANGYOU)) {
Thread.sleep(30000L);
continue;
}
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
int n = TenantBroker.applyAs(tenantId, tid -> orderService.processDueCompensate(20));
if (n > 0) {
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import lombok.RequiredArgsConstructor;
@@ -10,24 +11,31 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* YAML 可配 Cron 的日更入口(与 XXL Job 二选一即可)。
* YAML / 库表可配 Cron 的日更入口(与 XXL Job 二选一即可)。
* <p>
* 开关:{@code shangyou-hotel.spring-schedule-enabled}<br>
* 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron} / {@code local-daily-roll-cron}
* Cron 表达式本身仍读 YAML(改触发时刻需重启);是否执行读库表热配置
* {@code springScheduleEnabled} / 各项日更开关,测试环境关闭即可,无需重启。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelShangyouSpringSchedule {
private final ShangyouHotelProperties properties;
private final ShangyouHotelProperties yamlProperties;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelSyncService syncService;
private final SmHotelOrderService hotelOrderService;
private ShangyouHotelProperties props() {
return channelConfigService.resolveShangyouRuntime();
}
@Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}")
public void scheduledListSync() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -45,6 +53,7 @@ public class SmHotelShangyouSpringSchedule {
@Scheduled(cron = "${shangyou-hotel.price-roll-daily-cron:0 0 3 * * ?}")
public void scheduledPriceRoll() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -62,6 +71,7 @@ public class SmHotelShangyouSpringSchedule {
@Scheduled(cron = "${shangyou-hotel.local-daily-roll-cron:0 30 3 * * ?}")
public void scheduledLocalDailyRoll() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
@@ -80,12 +90,14 @@ public class SmHotelShangyouSpringSchedule {
/**
* App 待支付超时清理:默认每分钟扫一次,取消过期单并释放本地预占库存。
* 不依赖 spring-schedule-enabled(支付超时与日更无关)。
* 租户与订单补偿 Worker 一致,走 callback-tenant-id。
*/
@Scheduled(fixedDelayString = "${shangyou-hotel.app-pay-expire-scan-ms:60000}")
public void scheduledCancelExpiredAppUnpaid() {
try {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
ShangyouHotelProperties properties = props();
Long tenantId = properties.getCallbackTenantId() == null
? (yamlProperties.getCallbackTenantId() == null ? 1L : yamlProperties.getCallbackTenantId())
: properties.getCallbackTenantId();
TenantBroker.runAs(tenantId, tid -> hotelOrderService.cancelExpiredAppUnpaidOrders(100));
}
catch (Exception e) {
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelChannelConfigMapper extends PjcloudBaseMapper<SmHotelChannelConfigEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelChannelDeptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelChannelDeptMapper extends PjcloudBaseMapper<SmHotelChannelDeptEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelDownstreamPushLogMapper extends PjcloudBaseMapper<SmHotelDownstreamPushLogEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelOrderUpstreamLogMapper extends PjcloudBaseMapper<SmHotelOrderUpstreamLogEntity> {
}
@@ -0,0 +1,56 @@
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();
/** 渠道是否在库表中启用(无行时回落 YAML enabled;默认按上游) */
boolean isChannelEnabled(String channel);
boolean isChannelEnabled(String channel, String direction);
/** YAML/Nacos 默认快照,供后台表单对照 */
ShangyouHotelProperties yamlDefaultsSnapshot();
HotelDownstreamProperties downstreamYamlDefaultsSnapshot();
/** 已实现的下游协议列表(后台下拉) */
java.util.List<String> listDownstreamProtocols();
/** 已配置的下游伙伴(范围映射下拉,编码只在对接配置维护一次) */
java.util.List<SmHotelChannelConfigEntity> listDownstreamPartners();
}
@@ -0,0 +1,35 @@
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);
}
@@ -0,0 +1,14 @@
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.supermarket.entity.SmHotelDownstreamPushLogEntity;
public interface SmHotelDownstreamPushLogService extends IService<SmHotelDownstreamPushLogEntity> {
Page<SmHotelDownstreamPushLogEntity> pageLogs(Page<SmHotelDownstreamPushLogEntity> page,
SmHotelDownstreamPushLogEntity query);
/** 异步落库推送流水(失败不影响主流程) */
void record(SmHotelDownstreamPushLogEntity logEntity);
}
@@ -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,7 @@ public interface SmHotelOrderService {
* 预订单状态变更回调落库(验签后由上层调用)。
*/
Map<String, Object> handleOrderStatusNotify(Map<String, Object> body);
/** 上游调用流水(按时间升序) */
List<SmHotelOrderUpstreamLogEntity> listUpstreamLogs(Long orderId);
}
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.supermarket.service;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import java.util.List;
public interface SmHotelOrderUpstreamLogService {
void saveCall(Long orderId, String channel, String action, HotelUpstreamOrderResult result, String reqJson,
long costMs);
void saveSimple(Long orderId, String channel, String action, boolean success, String bizCode, String message,
String reqJson, String respJson, Long costMs);
List<SmHotelOrderUpstreamLogEntity> listByOrderId(Long orderId);
}
@@ -40,8 +40,9 @@ 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);
/**
* 根据某次同步日志的 failDetail,只重试失败酒店(支持 DETAIL_PULL / PRICE_ROLL)。
@@ -0,0 +1,474 @@
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 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 (dto.getTimeoutMs() != null && dto.getTimeoutMs() > 0) {
target.setTimeoutMs(dto.getTimeoutMs());
}
if (dto.getDailyDays() != null && dto.getDailyDays() > 0) {
target.setDailyDays(dto.getDailyDays());
}
}
private void overlayShangyou(ShangyouHotelProperties target, SmHotelShangyouChannelConfigDTO dto) {
if (StrUtil.isNotBlank(dto.getBaseUrl())) {
target.setBaseUrl(dto.getBaseUrl().trim());
}
if (dto.getAuthBaseUrl() != null) {
target.setAuthBaseUrl(StrUtil.trimToEmpty(dto.getAuthBaseUrl()));
}
if (dto.getBizBaseUrl() != null) {
target.setBizBaseUrl(StrUtil.trimToEmpty(dto.getBizBaseUrl()));
}
if (StrUtil.isNotBlank(dto.getClientId())) {
target.setClientId(dto.getClientId().trim());
}
if (StrUtil.isNotBlank(dto.getClientSecret())) {
target.setClientSecret(dto.getClientSecret().trim());
}
if (StrUtil.isNotBlank(dto.getCardId())) {
target.setCardId(dto.getCardId().trim());
}
if (StrUtil.isNotBlank(dto.getCardPassword())) {
target.setCardPassword(dto.getCardPassword().trim());
}
if (dto.getCallbackSecret() != null) {
target.setCallbackSecret(StrUtil.trimToEmpty(dto.getCallbackSecret()));
}
if (StrUtil.isNotBlank(dto.getBookerId())) {
target.setBookerId(dto.getBookerId().trim());
}
if (dto.getOrderIsGroup() != null) {
target.setOrderIsGroup(dto.getOrderIsGroup());
}
if (StrUtil.isNotBlank(dto.getOrderGuaranteeMode())) {
target.setOrderGuaranteeMode(dto.getOrderGuaranteeMode().trim());
}
if (StrUtil.isNotBlank(dto.getRoomPricePath())) {
target.setRoomPricePath(dto.getRoomPricePath().trim());
}
if (dto.getMemberLevels() != null) {
target.setMemberLevels(StrUtil.trimToEmpty(dto.getMemberLevels()));
}
if (dto.getTimeoutMs() != null && dto.getTimeoutMs() > 0) {
target.setTimeoutMs(dto.getTimeoutMs());
}
if (dto.getTokenSkewSeconds() != null && dto.getTokenSkewSeconds() >= 0) {
target.setTokenSkewSeconds(dto.getTokenSkewSeconds());
}
if (dto.getPriceDays() != null && dto.getPriceDays() > 0) {
target.setPriceDays(dto.getPriceDays());
}
if (dto.getHotelListCacheMinutes() != null && dto.getHotelListCacheMinutes() > 0) {
target.setHotelListCacheMinutes(dto.getHotelListCacheMinutes());
}
if (dto.getHotelListWarnSize() != null && dto.getHotelListWarnSize() > 0) {
target.setHotelListWarnSize(dto.getHotelListWarnSize());
}
if (dto.getCallbackTenantId() != null) {
target.setCallbackTenantId(dto.getCallbackTenantId());
}
if (dto.getCallbackQueueEnabled() != null) {
target.setCallbackQueueEnabled(dto.getCallbackQueueEnabled());
}
if (dto.getCallbackMaxAttempts() != null && dto.getCallbackMaxAttempts() > 0) {
target.setCallbackMaxAttempts(dto.getCallbackMaxAttempts());
}
if (dto.getCallbackTaskIntervalMs() != null && dto.getCallbackTaskIntervalMs() >= 0) {
target.setCallbackTaskIntervalMs(dto.getCallbackTaskIntervalMs());
}
if (dto.getPriceApiQps() != null) {
target.setPriceApiQps(dto.getPriceApiQps());
}
if (dto.getAppPayTimeoutMinutes() != null && dto.getAppPayTimeoutMinutes() > 0) {
target.setAppPayTimeoutMinutes(dto.getAppPayTimeoutMinutes());
}
if (dto.getSpringScheduleEnabled() != null) {
target.setSpringScheduleEnabled(dto.getSpringScheduleEnabled());
}
if (dto.getHotelListDailySyncEnabled() != null) {
target.setHotelListDailySyncEnabled(dto.getHotelListDailySyncEnabled());
}
if (StrUtil.isNotBlank(dto.getHotelListDailyCron())) {
target.setHotelListDailyCron(dto.getHotelListDailyCron().trim());
}
if (dto.getPriceRollDailyEnabled() != null) {
target.setPriceRollDailyEnabled(dto.getPriceRollDailyEnabled());
}
if (StrUtil.isNotBlank(dto.getPriceRollDailyCron())) {
target.setPriceRollDailyCron(dto.getPriceRollDailyCron().trim());
}
if (dto.getLocalDailyRollEnabled() != null) {
target.setLocalDailyRollEnabled(dto.getLocalDailyRollEnabled());
}
if (StrUtil.isNotBlank(dto.getLocalDailyRollCron())) {
target.setLocalDailyRollCron(dto.getLocalDailyRollCron().trim());
}
if (dto.getAutoImportNewHotels() != null) {
target.setAutoImportNewHotels(dto.getAutoImportNewHotels());
}
if (dto.getAutoImportBatchSize() != null && dto.getAutoImportBatchSize() > 0) {
target.setAutoImportBatchSize(dto.getAutoImportBatchSize());
}
if (dto.getPriceRollDayOffset() != null && dto.getPriceRollDayOffset() > 0) {
target.setPriceRollDayOffset(dto.getPriceRollDayOffset());
}
if (dto.getPriceRollBatchSize() != null && dto.getPriceRollBatchSize() > 0) {
target.setPriceRollBatchSize(dto.getPriceRollBatchSize());
}
if (dto.getPriceRollIntervalMs() != null && dto.getPriceRollIntervalMs() >= 0) {
target.setPriceRollIntervalMs(dto.getPriceRollIntervalMs());
}
if (dto.getFullPriceSyncMaxPerDay() != null && dto.getFullPriceSyncMaxPerDay() > 0) {
target.setFullPriceSyncMaxPerDay(dto.getFullPriceSyncMaxPerDay());
}
if (dto.getFullPriceSyncMinIntervalHours() != null && dto.getFullPriceSyncMinIntervalHours() >= 0) {
target.setFullPriceSyncMinIntervalHours(dto.getFullPriceSyncMinIntervalHours());
}
if (dto.getDetailPullIntervalMs() != null && dto.getDetailPullIntervalMs() >= 0) {
target.setDetailPullIntervalMs(dto.getDetailPullIntervalMs());
}
if (dto.getDetailPullMaxHotels() != null && dto.getDetailPullMaxHotels() >= 0) {
target.setDetailPullMaxHotels(dto.getDetailPullMaxHotels());
}
if (dto.getDiffScanBatchSize() != null && dto.getDiffScanBatchSize() > 0) {
target.setDiffScanBatchSize(dto.getDiffScanBatchSize());
}
if (dto.getAutoMarkOfflineHotels() != null) {
target.setAutoMarkOfflineHotels(dto.getAutoMarkOfflineHotels());
}
if (dto.getAutoReactivateOnlineHotels() != null) {
target.setAutoReactivateOnlineHotels(dto.getAutoReactivateOnlineHotels());
}
if (StrUtil.isNotBlank(dto.getDefaultImportDataSource())) {
target.setDefaultImportDataSource(dto.getDefaultImportDataSource().trim());
}
}
private static ShangyouHotelProperties copyProps(ShangyouHotelProperties src) {
ShangyouHotelProperties copy = new ShangyouHotelProperties();
BeanUtil.copyProperties(src, copy);
return copy;
}
private static HotelDownstreamProperties copyDownstreamProps(HotelDownstreamProperties src) {
HotelDownstreamProperties copy = new HotelDownstreamProperties();
BeanUtil.copyProperties(src, copy);
return copy;
}
private String normalizeChannelCode(String channel) {
return SmHotelDataSource.normalize(channel).toLowerCase();
}
private R<String> validateConfigText(String text) {
try {
ChannelConfigTextParser.validate(text);
return null;
}
catch (Exception e) {
return R.failed("对接参数格式错误(支持 YAML/# 注释 或 JSON):" + e.getMessage());
}
}
}
@@ -0,0 +1,374 @@
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());
}
private void normalizeEntity(SmHotelChannelDeptEntity entity) {
entity.setChannel(normalizeCode(entity.getChannel()));
entity.setDirection(SmHotelChannelDirection.normalize(entity.getDirection()));
if (SmHotelChannelDirection.isUpstream(entity.getDirection())) {
entity.setScopeType(SmHotelDownstreamScopeType.DEPT);
entity.setHotelIds(null);
entity.setDistrictAdcode(null);
entity.setDistrict(null);
return;
}
String scope = SmHotelDownstreamScopeType.normalize(entity.getScopeType());
entity.setScopeType(scope);
if (SmHotelDownstreamScopeType.isHotel(scope)) {
entity.setDeptId(null);
entity.setDistrictAdcode(null);
entity.setDistrict(null);
entity.setHotelIds(normalizeHotelIds(entity.getHotelIds()));
}
else {
entity.setHotelIds(null);
}
}
private R<String> validate(SmHotelChannelDeptEntity entity, Long excludeId) {
if (entity == null) {
return R.failed("参数不能为空");
}
String dir = SmHotelChannelDirection.normalize(entity.getDirection());
String ch = SmHotelDataSource.normalize(entity.getChannel());
if (StrUtil.isBlank(ch)) {
return R.failed(SmHotelChannelDirection.isDownstream(dir) ? "请选择下游伙伴" : "请选择第三方渠道");
}
if (SmHotelChannelDirection.isUpstream(dir) && SmHotelDataSource.isLocal(ch)) {
return R.failed("不能将「本地」配置为第三方渠道");
}
if (SmHotelChannelDirection.isUpstream(dir)) {
if (entity.getDeptId() == null) {
return R.failed("请选择归属部门");
}
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.UPSTREAM)
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed("该上游渠道已配置归属部门,请直接编辑");
}
return null;
}
String scope = SmHotelDownstreamScopeType.normalize(entity.getScopeType());
if (SmHotelDownstreamScopeType.isHotel(scope)) {
String ids = normalizeHotelIds(entity.getHotelIds());
if (StrUtil.isBlank(ids)) {
return R.failed("请选择要授权的酒店");
}
entity.setHotelIds(ids);
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getScopeType, SmHotelDownstreamScopeType.HOTEL)
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed("该下游伙伴已有「按指定酒店」配置,请直接编辑");
}
return null;
}
if (entity.getDeptId() == null) {
return R.failed("请选择要推送的部门");
}
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getChannel, ch.toLowerCase())
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.and(w -> w.eq(SmHotelChannelDeptEntity::getScopeType, SmHotelDownstreamScopeType.DEPT)
.or()
.isNull(SmHotelChannelDeptEntity::getScopeType)
.or()
.eq(SmHotelChannelDeptEntity::getScopeType, ""))
.eq(SmHotelChannelDeptEntity::getDeptId, entity.getDeptId())
.and(w -> {
if (entity.getDistrictAdcode() == null) {
w.isNull(SmHotelChannelDeptEntity::getDistrictAdcode);
}
else {
w.eq(SmHotelChannelDeptEntity::getDistrictAdcode, entity.getDistrictAdcode());
}
})
.ne(excludeId != null, SmHotelChannelDeptEntity::getId, excludeId));
if (cnt != null && cnt > 0) {
return R.failed(entity.getDistrictAdcode() == null
? "该下游伙伴已配置此部门(全部区域),请勿重复"
: "该下游伙伴已配置此部门+区域,请勿重复");
}
return null;
}
private static String normalizeHotelIds(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
Set<String> ids = new LinkedHashSet<>();
for (String part : raw.split("[,,\\s]+")) {
String p = StrUtil.trim(part);
if (StrUtil.isNotBlank(p) && p.matches("\\d+")) {
ids.add(p);
}
}
return ids.isEmpty() ? null : String.join(",", ids);
}
private static boolean containsHotelId(String hotelIds, Long hotelId) {
if (hotelId == null || StrUtil.isBlank(hotelIds)) {
return false;
}
String target = String.valueOf(hotelId);
for (String part : hotelIds.split(",")) {
if (target.equals(StrUtil.trim(part))) {
return true;
}
}
return false;
}
private String normalizeCode(String channel) {
return SmHotelDataSource.normalize(channel).toLowerCase();
}
private void fillDeptNames(List<SmHotelChannelDeptEntity> records) {
if (records == null || records.isEmpty()) {
return;
}
List<Long> deptIds = records.stream().map(SmHotelChannelDeptEntity::getDeptId).filter(Objects::nonNull)
.distinct().collect(Collectors.toList());
Map<Long, String> nameById = new HashMap<>();
if (!deptIds.isEmpty()) {
try {
R<Map<Long, SysDept>> r = remoteDeptService.getDeptByDeptIds(deptIds);
if (r != null && r.isOk() && r.getData() != null) {
r.getData().forEach((id, d) -> {
if (id != null && d != null) {
nameById.put(id, d.getName());
}
});
}
}
catch (Exception e) {
log.warn("|渠道部门|补全部门名失败|{}", e.getMessage());
}
}
for (SmHotelChannelDeptEntity row : records) {
if (row.getDeptId() != null) {
row.setDeptName(nameById.getOrDefault(row.getDeptId(), String.valueOf(row.getDeptId())));
}
row.setChannelLabel(row.getChannel());
if (StrUtil.isBlank(row.getDirection())) {
row.setDirection(SmHotelChannelDirection.UPSTREAM);
}
if (StrUtil.isBlank(row.getScopeType())) {
row.setScopeType(SmHotelDownstreamScopeType.DEPT);
}
}
}
private void fillHotelNames(List<SmHotelChannelDeptEntity> records) {
if (records == null || records.isEmpty()) {
return;
}
Set<Long> allIds = new HashSet<>();
for (SmHotelChannelDeptEntity row : records) {
if (!SmHotelDownstreamScopeType.isHotel(row.getScopeType()) || StrUtil.isBlank(row.getHotelIds())) {
continue;
}
for (String part : row.getHotelIds().split(",")) {
String p = StrUtil.trim(part);
if (p.matches("\\d+")) {
allIds.add(Long.valueOf(p));
}
}
}
if (allIds.isEmpty()) {
return;
}
List<SmHotelEntity> hotels = hotelMapper.selectByIds(allIds);
Map<Long, String> nameById = hotels.stream()
.filter(h -> h != null && h.getId() != null)
.collect(Collectors.toMap(SmHotelEntity::getId,
h -> StrUtil.blankToDefault(h.getHotelName(), String.valueOf(h.getId())), (a, b) -> a));
for (SmHotelChannelDeptEntity row : records) {
if (!SmHotelDownstreamScopeType.isHotel(row.getScopeType()) || StrUtil.isBlank(row.getHotelIds())) {
continue;
}
List<String> names = Arrays.stream(row.getHotelIds().split(","))
.map(StrUtil::trim)
.filter(p -> p.matches("\\d+"))
.map(Long::valueOf)
.map(id -> nameById.getOrDefault(id, String.valueOf(id)))
.collect(Collectors.toList());
row.setHotelNames(String.join("、", names));
}
}
}
@@ -0,0 +1,48 @@
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.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;
@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.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());
}
}
}
@@ -17,6 +17,8 @@ import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelClient;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
@@ -33,6 +35,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 中转核心:上游(玉编电云)拉取 → 加价落库 → 推下游。
@@ -61,6 +64,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
private final SmHotelPriceConfigService priceConfigService;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelChannelDeptService channelDeptService;
private final SmHotelMapper hotelMapper;
private final SmHotelRoomMapper roomMapper;
@@ -322,24 +329,104 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
@Override
public R<Map<String, Object>> pushToDownstream(Long hotelId) {
SmHotelEntity hotel = requireHotel(hotelId);
List<String> partners = resolvePushPartners(hotel);
if (CollUtil.isEmpty(partners)) {
return R.failed("该酒店未命中下游推送范围(请在「渠道范围映射」按部门+位置区域配置下游,并启用对接配置)");
}
List<Map<String, Object>> partnerResults = new ArrayList<>();
List<String> partnerErrors = new ArrayList<>();
for (String partner : partners) {
try {
Map<String, Object> one = pushHotelToPartner(hotel, partner);
one.put("partnerCode", partner);
partnerResults.add(one);
}
catch (Exception ex) {
partnerErrors.add(partner + ":" + ex.getMessage());
log.warn("|下游推送失败|hotelId={}|partner={}|err={}", hotelId, partner, ex.getMessage());
}
}
Map<String, Object> result = new HashMap<>(8);
result.put("partners", partners);
result.put("partnerResults", partnerResults);
result.put("partnerErrors", partnerErrors);
if (partnerResults.isEmpty()) {
return R.failed("全部下游推送失败:" + String.join("; ", partnerErrors));
}
return R.ok(result);
}
/**
* 按酒店部门 + 位置区域匹配下游伙伴:范围映射 ∩ 已启用对接配置。
* 若无命中映射但存在「default」YAML 兼容下游,则仍推送 default。
*/
private List<String> resolvePushPartners(SmHotelEntity hotel) {
List<String> enabled = channelConfigService.listEnabledDownstreamPartners();
if (CollUtil.isEmpty(enabled)) {
return Collections.emptyList();
}
List<String> matched = channelDeptService.listDownstreamPartnersForHotel(hotel.getId(), hotel.getDeptId(),
hotel.getDistrictAdcode(), hotel.getDistrict());
if (CollUtil.isEmpty(matched)) {
// 兼容:仅配置了 YAML 默认下游、未做范围映射时推 default
if (enabled.contains("default")) {
return List.of("default");
}
return Collections.emptyList();
}
Set<String> enabledSet = new HashSet<>(enabled);
return matched.stream().filter(enabledSet::contains).distinct().toList();
}
/** 按下游协议约定顺序推送:酒店→房型→产品→日历 */
private Map<String, Object> pushHotelToPartner(SmHotelEntity hotel, String partnerCode) {
Long hotelId = hotel.getId();
String extHotelId = StrUtil.blankToDefault(hotel.getHotelCode(), String.valueOf(hotel.getId()));
downstreamClient.assertPartnerReady(partnerCode);
Map<String, Object> hotelBody = new LinkedHashMap<>();
hotelBody.put("extHotelId", extHotelId);
hotelBody.put("hotelName", StrUtil.blankToDefault(hotel.getHotelName(), extHotelId));
if (StrUtil.isNotBlank(hotel.getDistrict())) {
hotelBody.put("cityName", hotel.getDistrict());
}
if (StrUtil.isNotBlank(hotel.getAddress())) {
hotelBody.put("address", hotel.getAddress());
}
if (StrUtil.isNotBlank(hotel.getPhone())) {
hotelBody.put("hotelPhone", hotel.getPhone());
}
parseRemarkAddress(hotel.getRemark(), hotelBody);
downstreamClient.hotelSync(hotelBody);
// 环启必填前台电话 receptionPhone;我方无独立前台号,复用 hotelPhone
Object phoneObj = hotelBody.get("hotelPhone");
String phone = phoneObj != null ? StrUtil.trim(String.valueOf(phoneObj)) : null;
if (StrUtil.isBlank(phone)) {
phone = StrUtil.trim(hotel.getPhone());
}
if (StrUtil.isNotBlank(phone)) {
hotelBody.put("hotelPhone", phone);
hotelBody.put("receptionPhone", phone);
}
downstreamClient.hotelSync(partnerCode, hotelId, extHotelId, hotelBody);
List<SmHotelRoomEntity> rooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
List<SmHotelRoomEntity> allRooms = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId));
List<SmHotelProductEntity> products = productMapper
List<SmHotelProductEntity> allProducts = productMapper
.selectList(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotelId));
// 可住人数为 0 的房型不推下游(空容量仍按 2 推)
List<SmHotelRoomEntity> rooms = allRooms.stream()
.filter(r -> r.getCapacity() == null || r.getCapacity() > 0)
.collect(Collectors.toList());
Set<String> pushedRoomCodes = rooms.stream()
.map(SmHotelRoomEntity::getRoomTypeCode)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(HashSet::new));
Map<String, Integer> roomQtyMap = new HashMap<>();
for (SmHotelRoomEntity room : rooms) {
int qty = room.getRoomNumber() == null ? 0 : room.getRoomNumber();
if (qty <= 0) {
qty = products.stream()
qty = allProducts.stream()
.filter(p -> StrUtil.equals(room.getRoomTypeCode(), p.getRoomTypeCode()))
.map(p -> p.getStock() == null ? 0 : p.getStock())
.max(Integer::compareTo)
@@ -357,7 +444,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
item.put("extApartmentId", room.getRoomTypeCode());
item.put("apartmentName", StrUtil.blankToDefault(room.getRoomTypeName(), room.getRoomTypeCode()));
item.put("bedType", room.getBedType());
item.put("capacity", room.getCapacity());
int capacity = room.getCapacity() == null ? 2 : room.getCapacity();
item.put("capacity", capacity);
item.put("apartmentNumber", roomQtyMap.getOrDefault(room.getRoomTypeCode(), 1));
list.add(item);
}
@@ -365,23 +453,43 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
body.put("list", batch);
downstreamClient.apartmentSync(body);
downstreamClient.apartmentSync(partnerCode, hotelId, extHotelId, body);
}
}
/*
* 产品:不看取消规则(本地无可靠取消数据,环启 canCancel=0 时也不必填)。
* 同房型+同早餐只能推 1 条:在有库存的候选里取价格最低的。
*/
List<String> productErrors = new ArrayList<>();
int productOk = 0;
Set<String> pricePlanKeys = new HashSet<>();
for (SmHotelProductEntity p : products) {
int productSkipped = 0;
Set<String> pushedProductCodes = new HashSet<>();
Map<String, List<SmHotelProductEntity>> productGroups = new LinkedHashMap<>();
for (SmHotelProductEntity p : allProducts) {
String roomCode = StrUtil.blankToDefault(p.getRoomTypeCode(), "_");
int breakfast = p.getMealCount() == null ? 0 : p.getMealCount();
int canCancel = p.getCanCancel() == null ? 0 : p.getCanCancel();
String cancelRule = StrUtil.nullToEmpty(p.getCancelRule());
String planKey = roomCode + "|" + breakfast + "|" + canCancel + "|" + cancelRule;
if (!pricePlanKeys.add(planKey)) {
productErrors.add(p.getProductCode() + ":跳过,同房型下早餐/取消规则与已推产品重复");
if (!pushedRoomCodes.contains(roomCode)) {
productSkipped++;
continue;
}
int breakfast = p.getMealCount() == null ? 0 : p.getMealCount();
String planKey = roomCode + "|" + breakfast;
productGroups.computeIfAbsent(planKey, k -> new ArrayList<>()).add(p);
}
for (Map.Entry<String, List<SmHotelProductEntity>> e : productGroups.entrySet()) {
List<SmHotelProductEntity> group = e.getValue();
List<SmHotelProductEntity> inStock = group.stream()
.filter(p -> p.getStock() != null && p.getStock() > 0)
.collect(Collectors.toList());
if (inStock.isEmpty()) {
productSkipped += group.size();
productErrors.add(e.getKey() + ":同房型+早餐均无库存,整组跳过");
continue;
}
SmHotelProductEntity p = inProductLowestPrice(inStock);
productSkipped += group.size() - 1;
String roomCode = StrUtil.blankToDefault(p.getRoomTypeCode(), "_");
int breakfast = p.getMealCount() == null ? 0 : p.getMealCount();
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
body.put("extApartmentId", roomCode);
@@ -389,21 +497,20 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
body.put("breakfastNumber", breakfast);
body.put("apartmentType", p.getApartmentType() == null ? 1 : p.getApartmentType());
body.put("productType", p.getProductType() == null ? 2 : p.getProductType());
body.put("canCancel", canCancel);
if (StrUtil.isNotBlank(p.getCancelRule())) {
body.put("cancelRule", p.getCancelRule());
}
// 不传取消规则,固定不可取消,避免环启要求 cancelRule
body.put("canCancel", 0);
BigDecimal price = p.getSellPrice() != null ? p.getSellPrice() : p.getCostPrice();
body.put("homePrice", price == null || price.compareTo(BigDecimal.ZERO) <= 0 ? BigDecimal.ONE : price);
body.put("homeNumber", roomQtyMap.getOrDefault(roomCode, 1));
try {
downstreamClient.productSync(body);
downstreamClient.productSync(partnerCode, hotelId, extHotelId, body);
productOk++;
pushedProductCodes.add(p.getProductCode());
}
catch (Exception ex) {
productErrors.add(p.getProductCode() + ":" + ex.getMessage());
log.warn("|下游产品推送失败|hotelId={}|product={}|err={}", hotelId, p.getProductCode(),
ex.getMessage());
log.warn("|下游产品推送失败|hotelId={}|partner={}|product={}|err={}", hotelId, partnerCode,
p.getProductCode(), ex.getMessage());
}
}
@@ -414,6 +521,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
int dailyPush = 0;
List<Map<String, Object>> dailyBatch = new ArrayList<>();
for (SmHotelDailyEntity d : dailies) {
if (!pushedRoomCodes.contains(d.getRoomTypeCode())
|| !pushedProductCodes.contains(d.getProductCode())) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("extApartmentId", d.getRoomTypeCode());
item.put("extProductId", d.getProductCode());
@@ -427,7 +538,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
body.put("list", dailyBatch);
downstreamClient.dailySync(body);
downstreamClient.dailySync(partnerCode, hotelId, extHotelId, body);
dailyPush += dailyBatch.size();
dailyBatch = new ArrayList<>();
}
@@ -436,17 +547,37 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
body.put("list", dailyBatch);
downstreamClient.dailySync(body);
downstreamClient.dailySync(partnerCode, hotelId, extHotelId, body);
dailyPush += dailyBatch.size();
}
Map<String, Object> result = new HashMap<>(8);
result.put("roomCount", rooms.size());
result.put("productCount", products.size());
result.put("roomSkippedCapacity0", allRooms.size() - rooms.size());
result.put("productCount", allProducts.size());
result.put("productPushOk", productOk);
result.put("productSkipped", productSkipped);
result.put("productErrors", productErrors);
result.put("dailyPushCount", dailyPush);
return R.ok(result);
return result;
}
/** 有库存候选里取卖价(无则成本价)最低的一条 */
private static SmHotelProductEntity inProductLowestPrice(List<SmHotelProductEntity> inStock) {
return inStock.stream().min((a, b) -> {
BigDecimal pa = a.getSellPrice() != null ? a.getSellPrice() : a.getCostPrice();
BigDecimal pb = b.getSellPrice() != null ? b.getSellPrice() : b.getCostPrice();
if (pa == null && pb == null) {
return 0;
}
if (pa == null) {
return 1;
}
if (pb == null) {
return -1;
}
return pa.compareTo(pb);
}).orElse(inStock.get(0));
}
@Override
@@ -641,6 +772,11 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
if (SmHotelDataSource.isLocal(source)) {
return R.failed("上游同步不能使用「本地」数据来源,请选择上游类来源");
}
R<Long> deptR = channelDeptService.requireEnabledDeptId(source);
if (deptR == null || !deptR.isOk() || deptR.getData() == null) {
return R.failed(deptR == null ? "渠道部门未配置" : deptR.getMsg());
}
Long deptId = deptR.getData();
int insert = 0;
int skip = 0;
for (Map<String, Object> row : hotels) {
@@ -659,7 +795,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
SmHotelEntity hotel = new SmHotelEntity();
hotel.setHotelCode(hotelId);
hotel.setHotelName(StrUtil.blankToDefault(hotelName, hotelId));
hotel.setSheetName(source);
hotel.setSheetName(SmHotelDataSource.normalize(source).toLowerCase());
hotel.setDeptId(deptId);
hotel.setUpstreamStatus("ACTIVE");
hotelMapper.insert(hotel);
stringRedisTemplate.opsForSet().add(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, hotelId);
@@ -669,6 +806,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
result.put("insertCount", insert);
result.put("skipCount", skip);
result.put("dataSource", source);
result.put("deptId", deptId);
return R.ok(result);
}
@@ -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;
@@ -40,6 +45,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -79,11 +85,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 +194,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 +234,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 +251,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 +447,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 +470,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);
@@ -600,9 +616,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 +636,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 +1007,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 +1067,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 +1104,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 +1397,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 +1410,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 +1458,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 +1492,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 +1581,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 +1613,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 +1629,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 +1641,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 +1794,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 +1814,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 +1892,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 +2028,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 +2103,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 +2202,108 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
return new BigDecimal(String.valueOf(o));
}
/**
* 解析上游网关;未注册渠道时落 UPSTREAM_FAIL。
*
* @param scheduleRefundOnUnsupported App 已付场景由调用方在 empty 后自行退款;此处仅落失败状态
*/
private Optional<HotelUpstreamOrderGateway> resolveUpstreamGateway(SmHotelEntity hotel, SmHotelOrderEntity order,
boolean scheduleRefundOnUnsupported) {
if (hotel == null) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("酒店不存在,无法提交上游");
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
return Optional.empty();
}
if (upstreamGatewayRegistry.isUnsupportedChannel(hotel.getSheetName())) {
String ch = upstreamGatewayRegistry.resolveChannelKey(hotel.getSheetName());
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("未接入的上游渠道: " + ch);
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
upstreamLogService.saveSimple(order.getId(), ch, "CREATE", false, "UNSUPPORTED",
order.getFailMsg(), null, null, 0L);
if (scheduleRefundOnUnsupported) {
// 退款由调用方在置 PAID 后触发
}
return Optional.empty();
}
Optional<HotelUpstreamOrderGateway> gateway = upstreamGatewayRegistry.find(hotel.getSheetName());
if (gateway.isEmpty()) {
order.setOrderStatus("UPSTREAM_FAIL");
order.setFailMsg("本地酒店不应走上游履约");
order.setCompensateStatus(COMP_NONE);
baseMapper.updateById(order);
}
return gateway;
}
private HotelUpstreamOrderResult invokeCreateOrder(HotelUpstreamOrderGateway gateway, SmHotelOrderEntity order,
com.alibaba.fastjson.JSONObject upstreamBody, String action) {
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult call;
try {
call = gateway.createOrder(upstreamBody);
}
catch (Exception e) {
call = HotelUpstreamOrderResult.uncertain(e.getMessage(), null);
}
upstreamLogService.saveCall(order.getId(), gateway.channel(), action, call,
upstreamBody == null ? null : upstreamBody.toJSONString(), System.currentTimeMillis() - t0);
return call;
}
/** 事务提交后触发自动退款(仅已支付订单) */
private void scheduleAutoRefundAfterUpstreamFail(SmHotelOrderEntity order, String reason) {
if (order == null || order.getId() == null) {
return;
}
SmHotelOrderEntity latest = baseMapper.selectById(order.getId());
if (latest == null) {
return;
}
if (!PAY_PAID.equals(latest.getPayStatus())) {
log.info("|酒店自动退款|跳过非已支付|orderId={}|payStatus={}|", latest.getId(), latest.getPayStatus());
return;
}
Long tenantId = latest.getTenantId() != null ? latest.getTenantId() : TenantContextHolder.getTenantId();
eventPublisher.publishEvent(new HotelUpstreamFailRefundEvent(latest.getId(), tenantId,
StrUtil.blankToDefault(reason, "上游下单失败自动退款")));
}
private R<Boolean> cancelUpstreamOrder(SmHotelOrderEntity order, String reason) {
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
Optional<HotelUpstreamOrderGateway> gatewayOpt = hotel == null ? Optional.empty()
: upstreamGatewayRegistry.find(hotel.getSheetName());
if (gatewayOpt.isEmpty()) {
return R.failed("无可用上游网关,无法取消");
}
HotelUpstreamOrderGateway gateway = gatewayOpt.get();
long t0 = System.currentTimeMillis();
HotelUpstreamOrderResult call = gateway.cancelOrder(order.getPlatformNo(), order.getHotelCode());
upstreamLogService.saveCall(order.getId(), gateway.channel(), "CANCEL", call,
order.getPlatformNo(), System.currentTimeMillis() - t0);
if (call.isUncertain()) {
return R.failed("上游取消不确定: " + call.getMessage());
}
if (!call.isBizOk()) {
return R.failed(StrUtil.blankToDefault(call.getMessage(), "上游取消返回失败"));
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
if (StrUtil.isNotBlank(reason)) {
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return R.ok(true);
}
@Override
public List<SmHotelOrderUpstreamLogEntity> listUpstreamLogs(Long orderId) {
return upstreamLogService.listByOrderId(orderId);
}
}
@@ -0,0 +1,82 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
import com.bsd.pjcloud.supermarket.integration.hotel.upstream.HotelUpstreamOrderResult;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderUpstreamLogMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderUpstreamLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
@Slf4j
@Service
public class SmHotelOrderUpstreamLogServiceImpl
extends ServiceImpl<SmHotelOrderUpstreamLogMapper, SmHotelOrderUpstreamLogEntity>
implements SmHotelOrderUpstreamLogService {
private static final int JSON_MAX = 8000;
@Override
public void saveCall(Long orderId, String channel, String action, HotelUpstreamOrderResult result, String reqJson,
long costMs) {
if (orderId == null || result == null) {
return;
}
boolean success = result.isBizOk() && !result.isUncertain();
String resp = null;
if (result.getRaw() != null) {
resp = result.getRaw().toJSONString();
}
else if (result.getContent() != null) {
resp = result.getContent().toJSONString();
}
saveSimple(orderId, channel, action, success, result.getCode(),
StrUtil.blankToDefault(result.getMessage(), result.getExceptionMsg()), reqJson, resp, costMs);
}
@Override
public void saveSimple(Long orderId, String channel, String action, boolean success, String bizCode, String message,
String reqJson, String respJson, Long costMs) {
if (orderId == null) {
return;
}
try {
SmHotelOrderUpstreamLogEntity row = new SmHotelOrderUpstreamLogEntity();
row.setOrderId(orderId);
row.setChannel(StrUtil.blankToDefault(channel, "-"));
row.setAction(StrUtil.blankToDefault(action, "-"));
row.setSuccess(success ? "1" : "0");
row.setBizCode(StrUtil.sub(bizCode, 0, 64));
row.setMessage(StrUtil.sub(message, 0, 500));
row.setReqJson(clip(reqJson));
row.setRespJson(clip(respJson));
row.setCostMs(costMs);
baseMapper.insert(row);
}
catch (Exception e) {
log.warn("|酒店上游流水|写入失败|orderId={}|action={}|{}", orderId, action, e.getMessage());
}
}
@Override
public List<SmHotelOrderUpstreamLogEntity> listByOrderId(Long orderId) {
if (orderId == null) {
return Collections.emptyList();
}
return baseMapper.selectList(Wrappers.<SmHotelOrderUpstreamLogEntity>lambdaQuery()
.eq(SmHotelOrderUpstreamLogEntity::getOrderId, orderId)
.orderByAsc(SmHotelOrderUpstreamLogEntity::getCreateTime));
}
private static String clip(String s) {
if (s == null) {
return null;
}
return s.length() <= JSON_MAX ? s : s.substring(0, JSON_MAX) + "...(truncated)";
}
}
@@ -13,6 +13,8 @@ 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;
@@ -68,12 +70,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 +99,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 +134,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 +146,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 +163,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 +173,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 +190,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 +199,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,28 +207,36 @@ 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, "已提交全量详情拉取");
}
@@ -243,7 +278,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 +386,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 +416,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 +458,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 +515,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 +565,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;
@@ -635,21 +670,27 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
* 对商游来源启用酒店逐家 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 +698,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 +710,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 +812,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 +924,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 +955,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 +1048,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 +1074,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 +1170,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 +1213,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 +1231,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 +1239,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 +1260,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 +1281,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 +1301,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public Map<String, Object> jobRollLocalDaily(String triggerType) {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!properties.isLocalDailyRollEnabled()) {
if (!props().isLocalDailyRollEnabled()) {
return Map.of("skipped", true, "reason", "local-daily-roll-enabled=false");
}
R<SmHotelSyncLogEntity> r = startLocalDailyRollAsync(StrUtil.blankToDefault(triggerType, "XXL"));
@@ -0,0 +1,70 @@
package com.bsd.pjcloud.supermarket.util;
import cn.hutool.core.util.StrUtil;
/**
* 行政区划 adcode 前缀匹配(与 App 列表筛选一致):
* 选省/市可覆盖全部下级区县。
*/
public final class HotelAreaAdcodeUtil {
private HotelAreaAdcodeUtil() {
}
/** 330000→33,330100→3301,330102→330102 */
public static String areaAdcodePrefix(long adcode) {
String s = String.valueOf(adcode);
if (s.length() > 6) {
return s.substring(0, 6);
}
if (s.length() == 6 && s.endsWith("0000")) {
return s.substring(0, 2);
}
if (s.length() == 6 && s.endsWith("00")) {
return s.substring(0, 4);
}
return s;
}
/**
* 酒店是否落在授权区域范围内。
*
* @param scopeAdcode 映射表配置的区域;null 表示不限区域
* @param scopeName 映射表区域名(名称兜底)
* @param hotelAdcode 酒店 districtAdcode
* @param hotelDistrict 酒店 district 名称
*/
public static boolean matchRegion(Long scopeAdcode, String scopeName, Long hotelAdcode, String hotelDistrict) {
if (scopeAdcode == null) {
return true;
}
if (hotelAdcode != null) {
String prefix = areaAdcodePrefix(scopeAdcode);
return String.valueOf(hotelAdcode).startsWith(prefix);
}
// 酒店未落 adcode:按区域名兜底
String name = StrUtil.trim(scopeName);
if (StrUtil.isBlank(name)) {
return false;
}
String hotelName = StrUtil.trim(hotelDistrict);
if (StrUtil.isBlank(hotelName)) {
return false;
}
if (hotelName.contains(name) || name.contains(hotelName)) {
return true;
}
String shortName = shortenAreaName(name);
return StrUtil.isNotBlank(shortName) && hotelName.contains(shortName);
}
public static String shortenAreaName(String name) {
if (StrUtil.isBlank(name)) {
return "";
}
String n = name.trim();
n = n.replaceAll("(壮族|回族|维吾尔|藏族|彝族|朝鲜族)?自治区$", "");
n = n.replaceAll("(特别行政区|省|市|区|县|旗|盟)$", "");
return n.trim();
}
}
@@ -0,0 +1,70 @@
package com.bsd.pjcloud.supermarket.util;
/**
* 去掉 JSON 中的行注释与块注释(字符串字面量内保留,避免误伤 https 地址)。
* 便于后台渠道对接配置带中文说明保存。
*/
public final class JsonCommentUtil {
private JsonCommentUtil() {
}
public static String strip(String input) {
if (input == null || input.isEmpty()) {
return input;
}
StringBuilder out = new StringBuilder(input.length());
boolean inString = false;
boolean escape = false;
boolean inLineComment = false;
boolean inBlockComment = false;
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
char next = i + 1 < input.length() ? input.charAt(i + 1) : 0;
if (inLineComment) {
if (c == '\n') {
inLineComment = false;
out.append(c);
}
continue;
}
if (inBlockComment) {
if (c == '*' && next == '/') {
inBlockComment = false;
i++;
}
continue;
}
if (inString) {
out.append(c);
if (escape) {
escape = false;
}
else if (c == '\\') {
escape = true;
}
else if (c == '"') {
inString = false;
}
continue;
}
if (c == '"') {
inString = true;
out.append(c);
continue;
}
if (c == '/' && next == '/') {
inLineComment = true;
i++;
continue;
}
if (c == '/' && next == '*') {
inBlockComment = true;
i++;
continue;
}
out.append(c);
}
return out.toString();
}
}
@@ -110,18 +110,13 @@ spring:
# # 同步后:已停用但上游又出现 → 自动恢复 ACTIVE
# auto-reactivate-online-hotels: true
#
## ========== 下游销售方推送(《第三方数据同步接口文档》)==========
## 本系统作为中转 A,主动把加价后的酒店/房价推到下游 B(与上面「上游」无关,可单独开关)
## ========== 下游销售方推送(环启等;优先走库表「渠道对接配置」direction=downstream)==========
## 本系统作为 A,主动 POST 到 B 的 /third/party/api/*;可配置多家;部门范围见「渠道部门映射」
## 下列 YAML 仅作无库表时的单下游兼容回落(partner=default)
#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