This commit is contained in:
dzc committed 2026-11-09 17:41:09 +08:00
commit 730a13ecb2
62 files changed
+4644 -397

No files matched your search

@@ -77,6 +77,24 @@ public class SmHotelShangyouChannelConfigDTO {
private String localDailyRollCron;
/** 上游渠道全量详情定时(全部启用酒店) */
private Boolean detailPullDailyEnabled;
private String detailPullDailyCron;
/** 渠道范围合集全量详情定时(各下游范围并集,跳过本地) */
private Boolean scopeDetailPullDailyEnabled;
private String scopeDetailPullDailyCron;
/** 近窗详情定时(范围合集未来 N 天;不占全量配额) */
private Boolean nearDetailPullEnabled;
/** 近窗天数,建议 7 或 15 */
private Integer nearDetailPullDays;
private String nearDetailPullCron;
private Boolean autoImportNewHotels;
private Integer autoImportBatchSize;
@@ -0,0 +1,60 @@
package com.bsd.pjcloud.supermarket.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
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;
/**
* App 出行/酒店入口可见部门白名单:配置部门及其子部门用户可见,未配置默认全员隐藏。
*/
@Data
@TenantTable
@TableName("sm_hotel_app_entry_dept")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "App出行入口可见部门")
public class SmHotelAppEntryDeptEntity extends Model<SmHotelAppEntryDeptEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "可见部门 ID(含其下子部门)")
private Long deptId;
@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;
}
@@ -39,7 +39,7 @@ public class SmHotelChannelDeptEntity extends Model<SmHotelChannelDeptEntity> {
@Schema(description = "位置区域名称(展示/名称兜底)")
private String district;
@Schema(description = "指定酒店 ID,逗号分隔(下游 hotel 模式)")
@Schema(description = "指定酒店 ID,逗号分隔(下游 hotel 模式;MEDIUMTEXT,可上千家)")
private String hotelIds;
@Schema(description = "备注")
@@ -0,0 +1,65 @@
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_mgr_push_config")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店管理推送配置")
public class SmHotelMgrPushConfigEntity extends Model<SmHotelMgrPushConfigEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "新订单推送:1开 0关")
private String newOrderEnabled;
@Schema(description = "新订单推送标题")
private String newOrderTitle;
@Schema(description = "新订单推送正文,可含 {hotelName}/{channelNo}")
private String newOrderContent;
@Schema(description = "日历提醒:1开 0关")
private String calendarRemindEnabled;
@Schema(description = "日历提醒标题")
private String calendarRemindTitle;
@Schema(description = "日历提醒正文")
private String calendarRemindContent;
@Schema(description = "每日推送整点小时,逗号分隔,如 7,12")
private String calendarRemindHours;
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,91 @@
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_mgr_push_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "酒店管理推送记录")
public class SmHotelMgrPushLogEntity extends Model<SmHotelMgrPushLogEntity> {
public static final String TYPE_NEW_ORDER = "NEW_ORDER";
public static final String TYPE_CALENDAR = "CALENDAR";
public static final String TRIGGER_AUTO = "AUTO";
public static final String TRIGGER_MANUAL = "MANUAL";
/** 全部成功 */
public static final String STATUS_OK = "1";
/** 部分成功 */
public static final String STATUS_PARTIAL = "2";
/** 失败/未投递 */
public static final String STATUS_FAIL = "0";
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
@Schema(description = "NEW_ORDER / CALENDAR")
private String pushType;
@Schema(description = "AUTO / MANUAL")
private String triggerType;
@Schema(description = "业务键")
private String bizId;
private Long hotelId;
private String hotelName;
@Schema(description = "部门ID逗号分隔")
private String deptIds;
@Schema(description = "部门名称逗号分隔")
private String deptNames;
@Schema(description = "推送人用户ID逗号分隔")
private String recipientUserIds;
@Schema(description = "推送人姓名逗号分隔")
private String recipientNames;
private Integer successCount;
private Integer failCount;
private String title;
private String content;
@Schema(description = "1全部成功 2部分成功 0失败")
private String status;
private String resultMsg;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -20,6 +20,9 @@ public class SmHotelOrderDetailVo {
@Schema(description = "订单主体")
private SmHotelOrderEntity order;
@Schema(description = "酒店确认单号(上游订单详情 orderId,现为酒店单号,方便客人与酒店核对)")
private String hotelConfirmNo;
@Schema(description = "酒店基础信息")
private HotelBrief hotel;
@@ -171,6 +171,48 @@ public class ShangyouHotelProperties {
/** 是否启用本地酒店自动滚加(按酒店 local_auto_roll 配置补齐缺失日历) */
private boolean localDailyRollEnabled = true;
/**
* 上游渠道「全量详情」定时开关(拉该渠道全部启用酒店;受 full-price-sync 次数/间隔限制)。
* 默认关闭,按需在渠道配置打开。
*/
private boolean detailPullDailyEnabled = false;
/**
* 全量详情 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 04:00:0 0 4 * * ?(注意与上游 6 小时间隔、每日次数上限)
*/
private String detailPullDailyCron = "0 0 4 * * ?";
/**
* 「渠道范围合集」全量详情定时开关:只拉全部下游范围映射并集中的上游酒店(跳过本地)。
* 默认关闭。
*/
private boolean scopeDetailPullDailyEnabled = false;
/**
* 渠道范围合集详情 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 05:00:0 0 5 * * ?
*/
private String scopeDetailPullDailyCron = "0 0 5 * * ?";
/**
* 近窗详情定时:只刷渠道范围合集「未来 N 天」房价(保持可售近窗新鲜;不占全量 6h/每日次数配额)。
* 默认开启。
*/
private boolean nearDetailPullEnabled = true;
/**
* 近窗天数:建议 7 或 15(≤30 通常只需 1 次 getRoomPrice/店)。
* 默认 15。
*/
private int nearDetailPullDays = 15;
/**
* 近窗详情 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每 2 小时:
*/
private String nearDetailPullCron = "0 0 */2 * * ?";
/** 文档:全量房价房量每天最多次数 */
private int fullPriceSyncMaxPerDay = 3;
@@ -20,4 +20,18 @@ public class SmHotelOpenProperties {
* 已是 http(s) 的上游图原样返回。未配置时仍返回库内原值(下游可能无法展示)。
*/
private String publicFileBaseUrl = "";
/**
* 是否每日向已启用资源回调的开放应用(如小如)推送全量同步信号。
* 对方收到 changeType=DAILY_FULL 后应主动拉酒店主数据/价态。
*/
private boolean dailyResourceNotifyEnabled = true;
/**
* 日更推送 Cron(默认每天 02:10,早于小如本地拉取 03:20,便于先推后补拉)。
*/
private String dailyResourceNotifyCron = "0 10 2 * * ?";
/** 日更推送使用的租户 */
private Long dailyNotifyTenantId = 1L;
}
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelProductMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelRoomMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelUserSearchPrefMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelAppEntryService;
import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
@@ -124,8 +125,16 @@ public class SmHotelAppController {
private final SmHotelAvailabilityNotifyService availabilityNotifyService;
private final SmHotelAppEntryService appEntryService;
private final StringRedisTemplate stringRedisTemplate;
@Operation(summary = "当前用户是否显示出行Tab/订单出行入口(按部门白名单,默认隐藏)")
@GetMapping("/entry-visible")
public R<Map<String, Object>> entryVisible() {
return R.ok(appEntryService.currentUserEntryVisible());
}
@Operation(summary = "列表筛选项(区域/星级/设施)")
@GetMapping("/filter-meta")
public R<Map<String, Object>> filterMeta() {
@@ -1080,10 +1089,20 @@ public class SmHotelAppController {
if (order == null) {
return R.failed("订单不存在");
}
// 上游订单:查详情接口,返显酒店单号(详情 orderId 现为酒店确认号)
String hotelConfirmNo = orderService.refreshUpstreamSnapshot(order);
order = orderService.getDetail(id);
if (order == null) {
return R.failed("订单不存在");
}
if (StrUtil.isBlank(hotelConfirmNo)) {
hotelConfirmNo = orderService.resolveHotelConfirmNo(order);
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", order.getId());
row.put("channelNo", order.getChannelNo());
row.put("platformNo", order.getPlatformNo());
row.put("hotelConfirmNo", hotelConfirmNo);
row.put("hotelId", order.getHotelId());
row.put("hotelCode", order.getHotelCode());
row.put("productCode", order.getProductCode());
@@ -0,0 +1,102 @@
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.SmHotelAppEntryDeptEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelAppEntryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 管理端:App 出行 Tab / 订单出行入口按部门显隐。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelAppEntry")
@Tag(name = "酒店App出行入口配置")
public class SmHotelAppEntryController {
private final SmHotelAppEntryService appEntryService;
@GetMapping("/page")
@Operation(summary = "分页")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_view')")
public R<Page<SmHotelAppEntryDeptEntity>> page(@ParameterObject Page<SmHotelAppEntryDeptEntity> page,
@ParameterObject SmHotelAppEntryDeptEntity query) {
return R.ok(appEntryService.page(page, query));
}
@GetMapping("/listEnabled")
@Operation(summary = "启用中的可见部门")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_view')")
public R<List<SmHotelAppEntryDeptEntity>> listEnabled() {
return R.ok(appEntryService.listEnabled());
}
@GetMapping("/enabledDeptIds")
@Operation(summary = "启用部门 ID 列表(树勾选回显)")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_view')")
public R<List<Long>> enabledDeptIds() {
return R.ok(appEntryService.listEnabledDeptIds());
}
@PostMapping("/replaceDepts")
@SysLog("配置App出行入口可见部门")
@Operation(summary = "整表替换可见部门(空=全员隐藏)")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_edit')")
public R<Boolean> replaceDepts(@RequestBody Map<String, Object> body) {
@SuppressWarnings("unchecked")
List<Object> raw = body == null ? null : (List<Object>) body.get("deptIds");
List<Long> deptIds = new java.util.ArrayList<>();
if (raw != null) {
for (Object o : raw) {
if (o == null) {
continue;
}
try {
deptIds.add(Long.valueOf(String.valueOf(o)));
}
catch (Exception ignored) {
// skip bad id
}
}
}
String remark = body == null || body.get("remark") == null ? null : String.valueOf(body.get("remark"));
return appEntryService.replaceEnabledDepts(deptIds, remark);
}
@PostMapping
@SysLog("新增App出行入口可见部门")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_edit')")
public R<Boolean> save(@RequestBody SmHotelAppEntryDeptEntity entity) {
return appEntryService.saveOne(entity);
}
@PutMapping
@SysLog("修改App出行入口可见部门")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_edit')")
public R<Boolean> update(@RequestBody SmHotelAppEntryDeptEntity entity) {
return appEntryService.updateOne(entity);
}
@DeleteMapping
@SysLog("删除App出行入口可见部门")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelAppEntry_edit')")
public R<Boolean> remove(@RequestBody List<Long> ids) {
return appEntryService.removeByIds(ids);
}
}
@@ -8,6 +8,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelService;
import com.bsd.pjcloud.supermarket.util.HotelAreaAdcodeUtil;
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +28,7 @@ import java.util.Map;
@RequestMapping("/smHotel")
@Tag(name = "酒店管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
@ApiCryptoAes
public class SmHotelController {
private final SmHotelService hotelService;
@@ -57,6 +57,9 @@ public class SmHotelHuanqiOrderController {
header(request, "sign"), rawBody);
partner = huanqiOrderService.resolvePartnerByAppKey(appKey);
JSONObject payload = JSONUtil.parseObj(rawBody);
if (StrUtil.isNotBlank(partner)) {
payload.set("partnerCode", partner);
}
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(payload);
if (r != null && r.getCode() == 0) {
resp.put("code", 200);
@@ -0,0 +1,97 @@
package com.bsd.pjcloud.supermarket.controller;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelMgrPushService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 酒店管理推送配置(新订单负责人 / 每日日历提醒)。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/smHotelMgrPush")
@Tag(name = "酒店管理推送配置")
public class SmHotelMgrPushController {
private final SmHotelMgrPushService mgrPushService;
@GetMapping("/config")
@Operation(summary = "获取推送配置")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelMgrPush_view')")
public R<SmHotelMgrPushConfigEntity> getConfig() {
return R.ok(mgrPushService.getOrCreateConfig());
}
@PutMapping("/config")
@SysLog("保存酒店管理推送配置")
@Operation(summary = "保存推送配置")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelMgrPush_edit')")
public R<Boolean> saveConfig(@RequestBody SmHotelMgrPushConfigEntity body) {
return mgrPushService.saveConfig(body);
}
@GetMapping("/log/page")
@Operation(summary = "推送记录分页")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelMgrPush_view')")
public R<Page<SmHotelMgrPushLogEntity>> pageLogs(@ParameterObject Page<SmHotelMgrPushLogEntity> page,
@RequestParam(required = false) String pushType) {
return R.ok(mgrPushService.pageLogs(page, pushType));
}
/**
* 管理端主动推送新订单:传 orderId 或 channelNo;都不传则推最近一单。
* orderId 用 String 接收,避免把 XR… 渠道单号误塞进 Long 参数导致 400。
*/
@PostMapping("/newOrder/run")
@SysLog("手工触发酒店新订单推送")
@Operation(summary = "主动推送新订单(忽略开关,可重复测发)")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelMgrPush_edit')")
public R<Integer> runNewOrderPush(@RequestParam(required = false) String orderId,
@RequestParam(required = false) String channelNo) {
Long id = null;
String no = StrUtil.trim(channelNo);
String rawId = StrUtil.trim(orderId);
if (StrUtil.isNotBlank(rawId)) {
if (rawId.matches("^\\d+$")) {
try {
id = Long.valueOf(rawId);
}
catch (NumberFormatException e) {
return R.failed("订单ID无效:" + rawId);
}
}
else {
// 前端误把渠道单号传到 orderId 时兜底
no = StrUtil.blankToDefault(no, rawId);
}
}
return mgrPushService.pushNewOrderManual(id, no);
}
/**
* 管理端手工触发日历提醒(联调/补发);hour 不传则用当前小时。
*/
@PostMapping("/calendarRemind/run")
@SysLog("手工触发酒店日历提醒推送")
@Operation(summary = "主动推送日历提醒(忽略小时白名单)")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelMgrPush_edit')")
public R<Integer> runCalendarRemind(@RequestParam(required = false) Integer hour) {
return mgrPushService.pushCalendarRemindManual(hour);
}
}
@@ -13,9 +13,11 @@ import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelAvailabilityNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
@@ -36,6 +38,7 @@ import java.util.Map;
@RequestMapping("/smHotel")
@Tag(name = "酒店中转(拉取/推送/加价)")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
@ApiCryptoAes
public class SmHotelMidController {
private final SmHotelMidService midService;
@@ -48,6 +51,8 @@ public class SmHotelMidController {
private final SmHotelAvailabilityNotifyService availabilityNotifyService;
private final SmHotelOpenResourceNotifyService openResourceNotifyService;
@Operation(summary = "查询全局售价调整配置(加价/折扣)")
@GetMapping("/priceConfig")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_view')")
@@ -183,6 +188,23 @@ public class SmHotelMidController {
return R.ok(data, started.getMsg());
}
@SysLog("通知开放应用全量拉取")
@Operation(summary = "对指定开放应用推送 DAILY_FULL,对方主动全量拉取(非协议下游推送)")
@PostMapping("/mid/notifyOpenDailyFull")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> notifyOpenDailyFull(@RequestParam("partnerCode") String partnerCode) {
int hit = openResourceNotifyService.notifyDailyFullToPartner(partnerCode);
if (hit <= 0) {
return R.failed("未找到启用回调的开放应用,或未配置回调地址:请先在「开放应用」里配好伙伴编码与资源回调");
}
Map<String, Object> data = new LinkedHashMap<>(4);
data.put("partnerCode", partnerCode);
data.put("notifiedApps", hit);
data.put("changeType", "DAILY_FULL");
data.put("message", "已通知对方全量拉取授权范围内酒店");
return R.ok(data, "已通知 " + hit + " 个开放应用");
}
@Operation(summary = "查询上游账号已授权酒店列表")
@GetMapping("/mid/authorizedHotels")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
@@ -413,6 +435,70 @@ public class SmHotelMidController {
return R.ok(data, started.getMsg());
}
@Operation(summary = "预览渠道范围可拉上游详情的酒店数(跳过本地)")
@GetMapping("/mid/pullScopeDetails/preview")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> previewPullScopeDetails(
@RequestParam(value = "partnerCode", required = false) String partnerCode) {
return R.ok(syncService.previewScopeDetailPull(partnerCode));
}
@SysLog("按渠道范围拉取上游全量详情")
@Operation(summary = "异步拉取指定下游渠道范围内的上游酒店全量详情(本地跳过),立刻返回 logId")
@PostMapping("/mid/pullScopeDetails")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullScopeDetails(@RequestParam("partnerCode") String partnerCode) {
R<SmHotelSyncLogEntity> started = syncService.startScopeDetailPullAsync(partnerCode, "MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("partnerCode", partnerCode);
data.put("message", "已提交,请到「上游同步日志」查看进度");
return R.ok(data, started.getMsg());
}
@SysLog("按渠道范围合集拉取上游全量详情")
@Operation(summary = "异步拉取全部下游渠道范围并集中的上游酒店全量详情(本地跳过),立刻返回 logId")
@PostMapping("/mid/pullScopeUnionDetails")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullScopeUnionDetails() {
R<SmHotelSyncLogEntity> started = syncService.startScopeUnionDetailPullAsync("MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("message", "已提交渠道范围合集详情拉取,请到「上游同步日志」查看进度");
return R.ok(data, started.getMsg());
}
@SysLog("近窗拉取上游详情")
@Operation(summary = "异步拉渠道范围近窗详情(默认未来 15 天;不占全量 6h 配额),立刻返回 logId")
@PostMapping("/mid/pullNearScopeDetails")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')")
public R<Map<String, Object>> pullNearScopeDetails(
@RequestParam(value = "partnerCode", required = false) String partnerCode,
@RequestParam(required = false) Integer days) {
R<SmHotelSyncLogEntity> started = syncService.startNearScopeDetailPullAsync(partnerCode, days, "MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("partnerCode", partnerCode);
data.put("nearDays", started.getData().getNewCount());
data.put("message", "已提交近窗详情拉取,请到「上游同步日志」查看进度");
return R.ok(data, started.getMsg());
}
@SysLog("重试同步失败酒店")
@Operation(summary = "根据某次同步日志的失败明细,只重试失败酒店(详情/滚价)")
@PostMapping("/mid/retryFailedFromLog")
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
@@ -22,6 +23,7 @@ import java.util.Map;
@RequestMapping("/smHotelOrder")
@Tag(name = "酒店中转订单")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
@ApiCryptoAes
public class SmHotelOrderController {
private final SmHotelOrderService orderService;
@@ -75,7 +77,8 @@ public class SmHotelOrderController {
@PostMapping("/reject/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel') or @pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> reject(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "人工拒绝接单"));
String reason = body == null ? null
: String.valueOf(body.getOrDefault("reason", "酒店暂无法确认预订,已为您取消并全额退款"));
return orderService.rejectLocalByAdmin(id, reason);
}
@@ -3,6 +3,8 @@ package com.bsd.pjcloud.supermarket.integration.downstream;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.common.core.exception.CheckedException;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
@@ -49,18 +51,46 @@ public class DownstreamInboundAuth {
public SmHotelDownstreamAppEntity verify(String appKey, String timestamp, String nonce, String sign,
String bodyJson) {
assertCommon(appKey, timestamp, nonce, sign);
SmHotelDownstreamAppEntity app = appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.last("limit 1"));
String key = StrUtil.trim(appKey);
assertCommon(key, timestamp, nonce, sign);
// 开放 API 请求通常无 TENANT-ID,过滤器会默认租户 1;跨租户按 appKey 查找,避免「密钥明明对却报无效」
SmHotelDownstreamAppEntity app = findOpenAppByKeyIgnoreTenant(key);
if (app == null) {
throw new CheckedException("appKey 无效");
log.warn("|开放API鉴权|appKey未命中|key={}|tenantCtx={}|", maskKey(key), TenantContextHolder.getTenantId());
throw new CheckedException("appKey 无效(请确认开放应用已在资安心对应环境创建且状态启用;线上库与测试库凭证可能不同)");
}
assertSign(appKey, app.getAppSecret(), timestamp, nonce, sign, bodyJson);
if (!"1".equals(StrUtil.trim(app.getStatus()))) {
throw new CheckedException("appKey 已停用");
}
// 后续酒店/范围查询切到应用所属租户
if (app.getTenantId() != null) {
TenantContextHolder.setTenantId(app.getTenantId());
}
assertSign(key, app.getAppSecret(), timestamp, nonce, sign, bodyJson);
return app;
}
/**
* 跳过租户条件按 appKey 查开放应用(含停用,便于区分「无效」与「停用」)。
*/
private SmHotelDownstreamAppEntity findOpenAppByKeyIgnoreTenant(String appKey) {
if (StrUtil.isBlank(appKey)) {
return null;
}
return TenantBroker.applyAs((Long) null, tid -> appMapper.selectOne(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
.orderByDesc(SmHotelDownstreamAppEntity::getStatus)
.last("limit 1")));
}
private static String maskKey(String appKey) {
String k = StrUtil.trim(appKey);
if (StrUtil.isBlank(k) || k.length() <= 6) {
return "***";
}
return k.substring(0, 4) + "***" + k.substring(k.length() - 2);
}
/**
* 环启 B→A 推送验签:appKey/secret 与渠道对接配置(下游 YAML)一致。
*/
@@ -5,6 +5,8 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDownstreamAppMapper;
@@ -16,15 +18,23 @@ import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
/**
* 开放平台 → 下游回调(订单状态等)。失败仅打日志,不阻断主流程。
* 开放平台 → 下游回调(订单状态等)。
* 即时重试数次;仍失败时通过 onResult(false) 交给业务侧落库定时补偿。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DownstreamOpenCallbackClient {
/** 单次通知内即时重试次数(含首次) */
private static final int INLINE_ATTEMPTS = 3;
/** 第 1/2/3 次发起前等待:0 → 5s → 15s(给对方短暂抖动/重启留余量) */
private static final long[] INLINE_BACKOFF_MS = { 0L, 5_000L, 15_000L };
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4, r -> {
Thread t = new Thread(r, "sm-hotel-open-callback");
t.setDaemon(true);
@@ -34,40 +44,143 @@ public class DownstreamOpenCallbackClient {
private final SmHotelDownstreamAppMapper appMapper;
public void notifyOrderStatusAsync(Long downstreamAppId, SmHotelOrderEntity order) {
notifyOrderStatusAsync(downstreamAppId, order, null, null);
}
/**
* @param changeType 可选:ORDER_REJECTED / CANCEL_APPROVED / CANCEL_APPLY / CANCEL_REJECTED 等
*/
public void notifyOrderStatusAsync(Long downstreamAppId, SmHotelOrderEntity order, String changeType) {
notifyOrderStatusAsync(downstreamAppId, order, changeType, null);
}
/**
* @param onResult 异步完成后回调:true=下游已成功接收;false=即时重试仍失败(需落库补偿)
*/
public void notifyOrderStatusAsync(Long downstreamAppId, SmHotelOrderEntity order, String changeType,
Consumer<Boolean> onResult) {
if (downstreamAppId == null || order == null) {
if (onResult != null) {
onResult.accept(false);
}
return;
}
// 异步线程无请求租户上下文;表带 @TenantTable,必须把当前租户带过去,否则查不到开放应用、回调静默跳过
Long tenantId = TenantContextHolder.getTenantId();
Map<String, Object> snapshot = snapshotOrder(order, changeType);
EXECUTOR.execute(() -> {
boolean ok = false;
try {
notifyOrderStatus(downstreamAppId, order);
if (tenantId != null) {
ok = Boolean.TRUE.equals(TenantBroker.applyAs(tenantId,
tid -> notifyOrderStatusWithRetry(downstreamAppId, snapshot)));
}
else {
ok = Boolean.TRUE.equals(TenantBroker.applyAs((Long) null,
tid -> notifyOrderStatusWithRetry(downstreamAppId, snapshot)));
}
}
catch (Exception e) {
log.warn("|开放回调|订单状态失败|appId={}|channelNo={}|{}", downstreamAppId, order.getChannelNo(),
e.getMessage());
log.warn("|开放回调|订单状态失败|appId={}|channelNo={}|{}", downstreamAppId,
snapshot.get("channelNo"), e.getMessage());
ok = false;
}
if (onResult != null) {
try {
onResult.accept(ok);
}
catch (Exception e) {
log.warn("|开放回调|结果回调异常|channelNo={}|{}", snapshot.get("channelNo"), e.getMessage());
}
}
});
}
public void notifyOrderStatus(Long downstreamAppId, SmHotelOrderEntity order) {
public boolean notifyOrderStatus(Long downstreamAppId, SmHotelOrderEntity order) {
return notifyOrderStatusWithRetry(downstreamAppId, snapshotOrder(order, null));
}
public boolean notifyOrderStatus(Long downstreamAppId, SmHotelOrderEntity order, String changeType) {
return notifyOrderStatusWithRetry(downstreamAppId, snapshotOrder(order, changeType));
}
private boolean notifyOrderStatusWithRetry(Long downstreamAppId, Map<String, Object> orderSnap) {
Exception last = null;
for (int i = 0; i < INLINE_ATTEMPTS; i++) {
long sleep = i < INLINE_BACKOFF_MS.length ? INLINE_BACKOFF_MS[i] : 2000L;
if (sleep > 0) {
try {
Thread.sleep(sleep);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return false;
}
}
try {
Boolean ok = doNotifyOrderStatusOnce(downstreamAppId, orderSnap);
if (ok == null) {
// 配置缺失:不再重试
return false;
}
if (Boolean.TRUE.equals(ok)) {
if (i > 0) {
log.info("|开放回调|即时重试成功|attempt={}|channelNo={}|", i + 1, orderSnap.get("channelNo"));
}
return true;
}
}
catch (Exception e) {
last = e;
log.warn("|开放回调|第{}次失败|channelNo={}|{}", i + 1, orderSnap.get("channelNo"), e.getMessage());
}
}
log.warn("|开放回调|即时重试耗尽|channelNo={}|last={}|", orderSnap.get("channelNo"),
last == null ? null : last.getMessage());
return false;
}
/**
* @return true 成功;false HTTP/业务失败可重试;null 配置问题不可重试
*/
private Boolean doNotifyOrderStatusOnce(Long downstreamAppId, Map<String, Object> orderSnap) {
SmHotelDownstreamAppEntity app = appMapper.selectById(downstreamAppId);
if (app == null || !"1".equals(app.getStatus()) || !"1".equals(app.getCallbackEnabled())) {
return;
log.warn("|开放回调|跳过|app缺失或未启用回调|appId={}|found={}|status={}|cb={}|", downstreamAppId,
app != null, app == null ? null : app.getStatus(),
app == null ? null : app.getCallbackEnabled());
return null;
}
if (StrUtil.isBlank(app.getCallbackBaseUrl())) {
return;
log.warn("|开放回调|跳过|未配置 callbackBaseUrl|appId={}|appKey={}|", downstreamAppId, app.getAppKey());
return null;
}
String path = StrUtil.blankToDefault(StrUtil.trim(app.getCallbackOrderPath()), "/order/status/notify");
String url = joinUrl(app.getCallbackBaseUrl(), path);
Map<String, Object> body = new LinkedHashMap<>(12);
body.put("channelNo", order.getChannelNo());
body.put("orderNo", order.getId() == null ? null : String.valueOf(order.getId()));
body.put("platformNo", order.getPlatformNo());
body.put("orderStatus", order.getOrderStatus());
body.put("bookStatus", order.getBookStatus());
body.put("hotelCode", order.getHotelCode());
body.put("productCode", order.getProductCode());
body.put("sellAmount", order.getSellAmount());
body.put("failMsg", order.getFailMsg());
body.put("channelNo", orderSnap.get("channelNo"));
body.put("orderNo", orderSnap.get("orderNo"));
body.put("platformNo", orderSnap.get("platformNo"));
body.put("orderStatus", orderSnap.get("orderStatus"));
body.put("bookStatus", orderSnap.get("bookStatus"));
body.put("hotelCode", orderSnap.get("hotelCode"));
body.put("productCode", orderSnap.get("productCode"));
body.put("sellAmount", orderSnap.get("sellAmount"));
body.put("failMsg", orderSnap.get("failMsg"));
String explicitType = orderSnap.get("changeType") == null ? null
: StrUtil.trim(String.valueOf(orderSnap.get("changeType")));
if (StrUtil.isNotBlank(explicitType)) {
body.put("changeType", explicitType);
}
else {
String st = StrUtil.trim(String.valueOf(orderSnap.get("orderStatus")));
if ("CANCELLED".equalsIgnoreCase(st)) {
body.put("changeType", "CANCEL_APPROVED");
}
else if ("PENDING_CANCEL".equalsIgnoreCase(st)) {
body.put("changeType", "CANCEL_APPLY");
}
}
String bodyJson = JSONUtil.toJsonStr(body);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
@@ -81,50 +194,35 @@ public class DownstreamOpenCallbackClient {
.body(bodyJson)
.timeout(15000)
.execute()) {
log.info("|开放回调|订单状态|appKey={}|url={}|http={}|channelNo={}|", app.getAppKey(), url, res.getStatus(),
order.getChannelNo());
log.info("|开放回调|订单状态|appKey={}|url={}|http={}|channelNo={}|orderStatus={}|body={}|",
app.getAppKey(), url, res.getStatus(), orderSnap.get("channelNo"),
orderSnap.get("orderStatus"), StrUtil.maxLength(res.body(), 300));
int http = res.getStatus();
if (http >= 200 && http < 300) {
return true;
}
log.warn("|开放回调|下游非2xx|http={}|url={}|", http, url);
return false;
}
}
/**
* 资源变更通知(房价/库存等)。路径未配置则跳过。
* 后续由本地价态变更事件批量触发;当前可供手动/任务调用。
*/
public void notifyResourceChangeAsync(Long downstreamAppId, String hotelCode, String changeType,
Object payload) {
if (downstreamAppId == null || StrUtil.isBlank(hotelCode)) {
return;
}
Long tenantId = TenantContextHolder.getTenantId();
EXECUTOR.execute(() -> {
try {
SmHotelDownstreamAppEntity app = appMapper.selectById(downstreamAppId);
if (app == null || !"1".equals(app.getStatus()) || !"1".equals(app.getCallbackEnabled())) {
return;
Runnable job = () -> doNotifyResourceChange(downstreamAppId, hotelCode, changeType, payload);
if (tenantId != null) {
TenantBroker.runAs(tenantId, tid -> job.run());
}
if (StrUtil.isBlank(app.getCallbackBaseUrl()) || StrUtil.isBlank(app.getCallbackResourcePath())) {
return;
}
String url = joinUrl(app.getCallbackBaseUrl(), app.getCallbackResourcePath());
Map<String, Object> body = new LinkedHashMap<>(8);
body.put("hotelCode", hotelCode);
body.put("changeType", StrUtil.blankToDefault(changeType, "DAILY"));
body.put("payload", payload);
String bodyJson = JSONUtil.toJsonStr(body);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = DownstreamSignHelper.sign(app.getAppKey(), app.getAppSecret(), timestamp, nonce,
bodyJson);
try (HttpResponse res = HttpRequest.post(url)
.header("appKey", app.getAppKey())
.header("timestamp", timestamp)
.header("nonce", nonce)
.header("sign", sign)
.header("Content-Type", "application/json;charset=UTF-8")
.body(bodyJson)
.timeout(15000)
.execute()) {
log.info("|开放回调|资源变更|appKey={}|hotel={}|http={}|", app.getAppKey(), hotelCode,
res.getStatus());
else {
TenantBroker.runAs((Long) null, tid -> job.run());
}
}
catch (Exception e) {
@@ -133,6 +231,96 @@ public class DownstreamOpenCallbackClient {
});
}
private void doNotifyResourceChange(Long downstreamAppId, String hotelCode, String changeType, Object payload) {
SmHotelDownstreamAppEntity app = appMapper.selectById(downstreamAppId);
if (app == null || !"1".equals(app.getStatus()) || !"1".equals(app.getCallbackEnabled())) {
return;
}
if (StrUtil.isBlank(app.getCallbackBaseUrl()) || StrUtil.isBlank(app.getCallbackResourcePath())) {
return;
}
String url = joinUrl(app.getCallbackBaseUrl(), app.getCallbackResourcePath());
Map<String, Object> body = new LinkedHashMap<>(8);
body.put("hotelCode", hotelCode);
body.put("changeType", StrUtil.blankToDefault(changeType, "DAILY"));
body.put("payload", payload);
String bodyJson = JSONUtil.toJsonStr(body);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = DownstreamSignHelper.sign(app.getAppKey(), app.getAppSecret(), timestamp, nonce, bodyJson);
Exception last = null;
for (int i = 0; i < INLINE_ATTEMPTS; i++) {
if (i > 0) {
try {
Thread.sleep(INLINE_BACKOFF_MS[Math.min(i, INLINE_BACKOFF_MS.length - 1)]);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
try (HttpResponse res = HttpRequest.post(url)
.header("appKey", app.getAppKey())
.header("timestamp", timestamp)
.header("nonce", nonce)
.header("sign", sign)
.header("Content-Type", "application/json;charset=UTF-8")
.body(bodyJson)
.timeout(15000)
.execute()) {
log.info("|开放回调|资源变更|appKey={}|hotel={}|http={}|attempt={}|", app.getAppKey(), hotelCode,
res.getStatus(), i + 1);
if (res.getStatus() >= 200 && res.getStatus() < 300) {
return;
}
}
catch (Exception e) {
last = e;
}
}
log.warn("|开放回调|资源变更重试耗尽|hotel={}|{}", hotelCode, last == null ? null : last.getMessage());
}
private static Map<String, Object> snapshotOrder(SmHotelOrderEntity order, String changeType) {
Map<String, Object> snap = new LinkedHashMap<>(12);
if (order == null) {
return snap;
}
snap.put("channelNo", order.getChannelNo());
snap.put("orderNo", order.getId() == null ? null : String.valueOf(order.getId()));
snap.put("platformNo", order.getPlatformNo());
// 酒店确认单号(上游详情 orderId);开放应用侧可返显给客人
String hotelConfirmNo = null;
try {
if (StrUtil.isNotBlank(order.getUpstreamRespJson())) {
com.alibaba.fastjson.JSONObject o = com.alibaba.fastjson.JSON
.parseObject(order.getUpstreamRespJson());
if (o != null) {
hotelConfirmNo = o.getString("hotelConfirmNo");
if (StrUtil.isBlank(hotelConfirmNo) && o.get("content") instanceof com.alibaba.fastjson.JSONObject c) {
hotelConfirmNo = c.getString("hotelConfirmNo");
}
}
}
}
catch (Exception ignored) {
// ignore
}
if (StrUtil.isNotBlank(hotelConfirmNo)) {
snap.put("hotelConfirmNo", hotelConfirmNo);
}
snap.put("orderStatus", order.getOrderStatus());
snap.put("bookStatus", order.getBookStatus());
snap.put("hotelCode", order.getHotelCode());
snap.put("productCode", order.getProductCode());
snap.put("sellAmount", order.getSellAmount());
snap.put("failMsg", order.getFailMsg());
if (StrUtil.isNotBlank(changeType)) {
snap.put("changeType", changeType.trim());
}
return snap;
}
private static String joinUrl(String base, String path) {
String b = StrUtil.removeSuffix(StrUtil.trim(base), "/");
String p = StrUtil.trim(path);
@@ -700,8 +700,9 @@ public class ShangyouHotelClient {
return ShangyouOrderCallResult.uncertain("取 Token 失败: " + e.getMessage(), null);
}
String url = props().resolveBizBaseUrl() + path;
log.info("shangyou order request path={} retried={} busyRound={} bodyKeys={}", path, retried, busyRound,
body.keySet());
// 完整业务体落日志(密码脱敏),便于与上游对「到底传了什么」
log.info("|上游订单请求|path={}|retried={}|busyRound={}|body={}|", path, retried, busyRound,
maskOrderBodyForLog(body));
try (HttpResponse response = HttpRequest.post(url)
.header("Authorization", "Bearer " + token)
.header("Accept", "*/*")
@@ -798,6 +799,26 @@ public class ShangyouHotelClient {
body.put("cardPassWord", props().getCardPassword());
}
/** 应用日志用:复制 body 并脱敏密码,避免明文进日志 */
private static String maskOrderBodyForLog(JSONObject body) {
if (body == null) {
return "null";
}
try {
JSONObject copy = JSON.parseObject(body.toJSONString());
if (copy.containsKey("cardPassWord")) {
copy.put("cardPassWord", "***");
}
if (copy.containsKey("cardPassword")) {
copy.put("cardPassword", "***");
}
return copy.toJSONString();
}
catch (Exception e) {
return String.valueOf(body.keySet());
}
}
private List<String> parseMemberLevels() {
if (StrUtil.isBlank(props().getMemberLevels())) {
return Collections.emptyList();
@@ -0,0 +1,49 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.service.SmHotelMgrPushService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 酒店管理日历提醒定时任务:默认每小时整点检查配置中的小时列表(默认 7、12)。
* 与有房通知、开放资源日更等任务相互独立。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelMgrPushSchedule {
private final SmHotelMgrPushService mgrPushService;
/** 定时任务是否启用(可用配置关闭,避免多实例重复时可改 false 改走 XXL)。 */
@Value("${sm-hotel.mgr-push.schedule-enabled:true}")
private boolean scheduleEnabled;
/** 执行租户,默认 1。 */
@Value("${sm-hotel.mgr-push.tenant-id:1}")
private Long tenantId;
@Scheduled(cron = "${sm-hotel.mgr-push.calendar-cron:0 0 * * * ?}")
public void scheduledCalendarRemind() {
if (!scheduleEnabled) {
return;
}
Long tid = tenantId == null ? 1L : tenantId;
int hour = LocalDateTime.now().getHour();
try {
int n = TenantBroker.applyAs(tid, t -> mgrPushService.runCalendarRemind(hour, false));
if (n > 0) {
log.info("|酒店管理推送|定时日历提醒|tenant={}|hour={}|ok={}|", tid, hour, n);
}
}
catch (Exception e) {
log.error("|酒店管理推送|定时日历提醒异常|hour={}|", hour, e);
}
}
}
@@ -0,0 +1,43 @@
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 开放应用(小如等)订单状态回调失败后的定时重推。
* 不依赖尚游日更开关:对方维护恢复后自动补推拒单/取消等状态。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelOpenCallbackRetrySchedule {
private final ShangyouHotelProperties yamlProperties;
private final SmHotelChannelConfigService channelConfigService;
private final SmHotelOrderService hotelOrderService;
@Scheduled(fixedDelayString = "${sm-hotel.open-callback-retry-ms:60000}")
public void scheduledRetry() {
try {
ShangyouHotelProperties properties = channelConfigService.resolveShangyouRuntime();
Long tenantId = properties.getCallbackTenantId() == null
? (yamlProperties.getCallbackTenantId() == null ? 1L : yamlProperties.getCallbackTenantId())
: properties.getCallbackTenantId();
int n = TenantBroker.applyAs(tenantId, tid -> hotelOrderService.processOpenCallbackRetry(30));
if (n > 0) {
log.info("|开放回调补偿|本轮处理 {} 单", n);
}
}
catch (Exception e) {
log.error("|开放回调补偿|定时任务异常", e);
}
}
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.SmHotelOpenProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 开放应用资源日更:向小如等推送 DAILY_FULL,由对方主动全量拉取。
* 与变更即时回调互补;对方另有定时拉取作兜底。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelOpenResourceDailyNotifySchedule {
private final SmHotelOpenProperties openProperties;
private final SmHotelOpenResourceNotifyService openResourceNotifyService;
@Scheduled(cron = "${sm-hotel.open.daily-resource-notify-cron:0 10 2 * * ?}")
public void scheduledDailyNotify() {
if (openProperties == null || !openProperties.isDailyResourceNotifyEnabled()) {
return;
}
Long tenantId = openProperties.getDailyNotifyTenantId() == null ? 1L
: openProperties.getDailyNotifyTenantId();
try {
int n = TenantBroker.applyAs(tenantId, tid -> openResourceNotifyService.notifyDailyFullToOpenApps());
if (n > 0) {
log.info("|开放资源日更|已通知 {} 个开放应用拉取全量", n);
}
}
catch (Exception e) {
log.error("|开放资源日更|异常", e);
}
}
}
@@ -87,6 +87,62 @@ public class SmHotelShangyouSpringSchedule {
}
}
@Scheduled(cron = "${shangyou-hotel.detail-pull-daily-cron:0 0 4 * * ?}")
public void scheduledDetailPull() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
if (!properties.isDetailPullDailyEnabled()) {
return;
}
try {
log.info("|酒店日更|Spring Cron 触发全量详情|cron={}|", properties.getDetailPullDailyCron());
syncService.jobDetailPull("CRON");
}
catch (Exception e) {
log.error("|酒店日更|Spring 全量详情异常", e);
}
}
@Scheduled(cron = "${shangyou-hotel.scope-detail-pull-daily-cron:0 0 5 * * ?}")
public void scheduledScopeDetailPull() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
if (!properties.isScopeDetailPullDailyEnabled()) {
return;
}
try {
log.info("|酒店日更|Spring Cron 触发渠道范围合集详情|cron={}|",
properties.getScopeDetailPullDailyCron());
syncService.jobScopeDetailPull("CRON");
}
catch (Exception e) {
log.error("|酒店日更|Spring 渠道范围合集详情异常", e);
}
}
@Scheduled(cron = "${shangyou-hotel.near-detail-pull-cron:0 0 */2 * * ?}")
public void scheduledNearDetailPull() {
ShangyouHotelProperties properties = props();
if (!properties.isSpringScheduleEnabled()) {
return;
}
if (!properties.isNearDetailPullEnabled()) {
return;
}
try {
log.info("|酒店日更|Spring Cron 触发近窗详情|days={}|cron={}|", properties.getNearDetailPullDays(),
properties.getNearDetailPullCron());
syncService.jobNearDetailPull("CRON");
}
catch (Exception e) {
log.error("|酒店日更|Spring 近窗详情异常", e);
}
}
/**
* App 待支付超时清理:默认每分钟扫一次,取消过期单并释放本地预占库存。
* 不依赖 spring-schedule-enabled(支付超时与日更无关)。
@@ -15,6 +15,9 @@ import java.util.Map;
* <li>{@link #HANDLER_LIST_SYNC}:getHotelList + Redis 差集 + 可选自动导入新增</li>
* <li>{@link #HANDLER_PRICE_ROLL}:对第三方酒店只拉 today+offset 一天房价</li>
* <li>{@link #HANDLER_LOCAL_ROLL}:本地酒店按配置补齐日历窗口</li>
* <li>{@link #HANDLER_DETAIL_PULL}:上游渠道全量详情(受 6h/每日次数限制)</li>
* <li>{@link #HANDLER_SCOPE_DETAIL_PULL}:全部下游渠道范围合集详情(跳过本地)</li>
* <li>{@link #HANDLER_NEAR_DETAIL_PULL}:渠道范围近窗详情(未来 7/15 天,不占全量配额)</li>
* </ul>
* 频率在 XXL-JOB 控制台配 Cron;开关与参数在 shangyou-hotel.*。
*/
@@ -29,6 +32,12 @@ public class SmHotelShangyouSyncJob {
public static final String HANDLER_LOCAL_ROLL = "smHotelLocalDailyRollJobHandler";
public static final String HANDLER_DETAIL_PULL = "smHotelDetailPullJobHandler";
public static final String HANDLER_SCOPE_DETAIL_PULL = "smHotelScopeDetailPullJobHandler";
public static final String HANDLER_NEAR_DETAIL_PULL = "smHotelNearDetailPullJobHandler";
private final SmHotelSyncService syncService;
@XxlJob(HANDLER_LIST_SYNC)
@@ -69,4 +78,43 @@ public class SmHotelShangyouSyncJob {
XxlJobHelper.handleFail(e.getMessage());
}
}
@XxlJob(HANDLER_DETAIL_PULL)
public void smHotelDetailPullJobHandler() {
try {
Map<String, Object> r = syncService.jobDetailPull();
XxlJobHelper.log("hotel detail pull: " + r);
XxlJobHelper.handleSuccess(String.valueOf(r));
}
catch (Exception e) {
log.error("smHotelDetailPullJobHandler 异常", e);
XxlJobHelper.handleFail(e.getMessage());
}
}
@XxlJob(HANDLER_SCOPE_DETAIL_PULL)
public void smHotelScopeDetailPullJobHandler() {
try {
Map<String, Object> r = syncService.jobScopeDetailPull();
XxlJobHelper.log("hotel scope detail pull: " + r);
XxlJobHelper.handleSuccess(String.valueOf(r));
}
catch (Exception e) {
log.error("smHotelScopeDetailPullJobHandler 异常", e);
XxlJobHelper.handleFail(e.getMessage());
}
}
@XxlJob(HANDLER_NEAR_DETAIL_PULL)
public void smHotelNearDetailPullJobHandler() {
try {
Map<String, Object> r = syncService.jobNearDetailPull();
XxlJobHelper.log("hotel near detail pull: " + r);
XxlJobHelper.handleSuccess(String.valueOf(r));
}
catch (Exception e) {
log.error("smHotelNearDetailPullJobHandler 异常", e);
XxlJobHelper.handleFail(e.getMessage());
}
}
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelAppEntryDeptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelAppEntryDeptMapper extends PjcloudBaseMapper<SmHotelAppEntryDeptEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushConfigEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelMgrPushConfigMapper extends PjcloudBaseMapper<SmHotelMgrPushConfigEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushLogEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmHotelMgrPushLogMapper extends PjcloudBaseMapper<SmHotelMgrPushLogEntity> {
}
@@ -1,9 +1,57 @@
package com.bsd.pjcloud.supermarket.mapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Mapper
public interface SmHotelOrderMapper extends PjcloudBaseMapper<SmHotelOrderEntity> {
/**
* 管家数据看板:按「今日/昨日」聚合。deptIds 为空集合时无数据;为 null 时不按部门限制(平台管理员)。
*/
@Select("""
<script>
SELECT
CASE WHEN o.create_time &gt;= #{todayStart} THEN 'TODAY' ELSE 'YESTERDAY' END AS dayKey,
COUNT(1) AS orderCount,
COALESCE(SUM(
GREATEST(IFNULL(o.count, 1), 1) * CASE
WHEN o.check_in_date IS NOT NULL
AND o.check_out_date IS NOT NULL
AND o.check_out_date &gt; o.check_in_date
THEN GREATEST(DATEDIFF(o.check_out_date, o.check_in_date), 1)
ELSE 1
END
), 0) AS roomNights,
COALESCE(SUM(IFNULL(o.sell_amount, 0)), 0) AS sellAmount
FROM canteen_hotel_order o
INNER JOIN canteen_hotel h ON h.id = o.hotel_id AND IFNULL(h.del_flag, '0') = '0'
WHERE IFNULL(o.del_flag, '0') = '0'
AND o.order_status &lt;&gt; 'CANCELLED'
AND o.create_time &gt;= #{yesterdayStart}
AND o.create_time &lt; #{tomorrowStart}
<choose>
<when test="deptIds != null and deptIds.size() == 0">
AND 1 = 0
</when>
<when test="deptIds != null and deptIds.size() &gt; 0">
AND h.dept_id IN
<foreach collection="deptIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</when>
</choose>
GROUP BY CASE WHEN o.create_time &gt;= #{todayStart} THEN 'TODAY' ELSE 'YESTERDAY' END
</script>
""")
List<Map<String, Object>> selectManagerDashboardAgg(@Param("yesterdayStart") LocalDateTime yesterdayStart,
@Param("todayStart") LocalDateTime todayStart, @Param("tomorrowStart") LocalDateTime tomorrowStart,
@Param("deptIds") Collection<Long> deptIds);
}
@@ -0,0 +1,41 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelAppEntryDeptEntity;
import java.util.List;
import java.util.Map;
/**
* App 出行/酒店入口按部门显隐。
*/
public interface SmHotelAppEntryService {
Page<SmHotelAppEntryDeptEntity> page(Page<SmHotelAppEntryDeptEntity> page, SmHotelAppEntryDeptEntity query);
List<SmHotelAppEntryDeptEntity> listEnabled();
/** 当前启用的可见部门 id 列表(管理端树勾选回显) */
List<Long> listEnabledDeptIds();
/**
* 整表替换启用部门:传入的 deptIds 启用,其余软删。
* 空列表 = 全员隐藏。
*/
R<Boolean> replaceEnabledDepts(List<Long> deptIds, String remark);
R<Boolean> saveOne(SmHotelAppEntryDeptEntity entity);
R<Boolean> updateOne(SmHotelAppEntryDeptEntity entity);
R<Boolean> removeByIds(List<Long> ids);
/**
* 当前登录用户是否可见出行 Tab / 订单出行入口。
* 未配置任何启用部门 → false;用户部门命中白名单或其子树 → true。
*/
boolean isTravelVisibleForCurrentUser();
Map<String, Object> currentUserEntryVisible();
}
@@ -38,4 +38,10 @@ public interface SmHotelChannelDeptService extends IService<SmHotelChannelDeptEn
* 用于「按下游范围全量推送」。
*/
List<Long> listHotelIdsForDownstreamPartner(String partnerCode);
/**
* 下游:全部启用渠道范围映射的上架酒店 ID 合集(各伙伴并集去重)。
* 用于「按渠道范围合集拉上游全量详情」。
*/
List<Long> listHotelIdsUnionAllDownstreamScopes();
}
@@ -24,4 +24,10 @@ public interface SmHotelDownstreamAppService extends IService<SmHotelDownstreamA
/** 订单来源编码:优先 partnerCode,否则 appKey */
String resolvePartnerCode(SmHotelDownstreamAppEntity app);
/**
* 是否为启用中的开放应用(partnerCode 或 appKey 命中)。
* 开放应用走拉模式,不走「渠道对接配置」推送。
*/
boolean isEnabledOpenAppPartner(String partnerCode);
}
@@ -1,5 +1,7 @@
package com.bsd.pjcloud.supermarket.service;
import java.time.LocalDate;
/**
* 下游资源自动推送:本地/回调变更后防抖推;日更按授权范围扫。
*/
@@ -10,6 +12,16 @@ public interface SmHotelDownstreamAutoPushService {
*/
void scheduleAfterHotelChanged(Long hotelId);
/**
* 下游可订校验失败后异步补偿:上游酒店先按入住区间重拉价态,再向该伙伴推送最新资源;
* 本地酒店跳过上游拉取,直接向该伙伴推送。partnerCode 为空时仅重拉(上游)并走通用自动推。
* <p>
* productId 非空时:重拉后仍强制把该产品在入住区间内本地日历关房(上游 checkAvailability
* 已售空时,价态列表常仍显示有房,避免再推「开房」)。
*/
void scheduleRefreshAndPushAfterCheckFail(Long hotelId, String partnerCode, LocalDate checkIn,
LocalDate checkOut, Long productId);
/**
* 删产品前:对指定产品先向下游关房(避免删光后无法组关房报文)。
*/
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushLogEntity;
/**
* 酒店管理推送:配置维护 + 新订单负责人群发 + 每日日历提醒。
*/
public interface SmHotelMgrPushService {
/** 获取当前租户配置(无则创建默认行)。 */
SmHotelMgrPushConfigEntity getOrCreateConfig();
/** 保存当前租户配置。 */
R<Boolean> saveConfig(SmHotelMgrPushConfigEntity body);
/** 推送记录分页。 */
Page<SmHotelMgrPushLogEntity> pageLogs(Page<SmHotelMgrPushLogEntity> page, String pushType);
/**
* 新订单推送(业务自动触发):配置关闭 / 无负责人则跳过;幂等 eventId。
*
* @param orderId 订单主键
*/
void notifyNewOrder(Long orderId);
/**
* 管理端主动推送新订单(可按订单ID或渠道单号;force 忽略开关并允许重复推)。
*
* @param orderId 订单主键,可空
* @param channelNo 渠道单号,可空;与 orderId 至少填一个,都空则取最近一单
* @return 成功投递人数;失败返回 R.failed
*/
R<Integer> pushNewOrderManual(Long orderId, String channelNo);
/**
* 日历提醒:对「有酒店归属」的各部门负责人去重后推送。
*
* @param hour 当前整点小时(用于匹配配置 hours 与幂等日键)
* @param force true 时忽略小时白名单(管理端手工补发)
* @return 成功投递人数(Feign 成功计)
*/
int runCalendarRemind(int hour, boolean force);
/**
* 管理端主动推送日历提醒(带明确失败原因)。
*/
R<Integer> pushCalendarRemindManual(Integer hour);
}
@@ -13,9 +13,27 @@ public interface SmHotelOpenResourceNotifyService {
String CHANGE_DETAIL = "HOTEL_DETAIL";
/** 日更:通知下游主动全量拉取(非单店明细) */
String CHANGE_DAILY_FULL = "DAILY_FULL";
/** 按酒店主键通知(自动解析 hotelCode) */
void notifyByHotelId(Long hotelId, String changeType);
/** 按酒店编码通知(删除前可先记下 code) */
void notifyByHotelCode(String hotelCode, Long hotelId, String changeType);
/**
* 日更:对所有启用资源回调的开放应用推送 DAILY_FULL(hotelCode=*)。
*
* @return 实际推送的应用数
*/
int notifyDailyFullToOpenApps();
/**
* 按伙伴编码通知指定开放应用全量拉取(DAILY_FULL)。
* partnerCode 匹配开放应用的 partnerCode 或 appKey。
*
* @return 实际推送的应用数;0 表示未命中或未配回调
*/
int notifyDailyFullToPartner(String partnerCode);
}
@@ -31,6 +31,15 @@ public interface SmHotelOrderService {
/** PC 后台详情:订单 + 酒店基础信息 + 产品 + 入住日历 + 入住人 */
SmHotelOrderDetailVo getAdminDetail(Long id);
/**
* 调上游订单详情刷新 book/pay 状态,并把详情返回的 orderId(现为酒店单号)写入快照供返显。
* @return 酒店确认单号;本地单或无法查询时可能为空
*/
String refreshUpstreamSnapshot(SmHotelOrderEntity order);
/** 从上游快照解析酒店确认单号(详情接口 orderId);无则空 */
String resolveHotelConfirmNo(SmHotelOrderEntity order);
/** 下游回传下单 → 可定校验 → 上游创建 → 必要时挂补偿 */
R<JSONObject> createFromDownstream(String appKey, Map<String, Object> body);
@@ -116,6 +125,12 @@ public interface SmHotelOrderService {
/** Worker:扫描到期补偿任务 */
int processDueCompensate(int limit);
/**
* Worker:开放应用订单状态回调失败后的定时重推(对方维护/网络抖动)。
* 依赖 lastCompensateMsg=OPEN_CB_NEED|... 与 nextCompensateTime。
*/
int processOpenCallbackRetry(int limit);
/** PC 手工立刻补偿查询 */
R<Boolean> compensateNow(Long id);
@@ -18,12 +18,12 @@ public interface SmHotelService extends IService<SmHotelEntity> {
void applySaleableFilter(LambdaQueryWrapper<SmHotelEntity> wrapper, String saleable);
/**
* 按数据权限取酒店;不在部门范围内时抛 IllegalArgumentException。
* 用于详情/编辑/房型日历等写读,防止跨部门 IDOR。
* 按酒店商户部门隔离取酒店;不在范围内时抛 IllegalArgumentException。
* 用于详情/编辑/房型日历等写读,防止跨商户 IDOR。
*/
SmHotelEntity getByIdInScope(Long id);
/** 校验酒店在当前用户数据权限范围内(仅校验,不返回实体) */
/** 校验酒店在当前用户酒店商户范围内(仅校验,不返回实体) */
void assertHotelInScope(Long id);
SmHotelEntity saveHotel(SmHotelEntity entity);
@@ -44,6 +44,31 @@ public interface SmHotelSyncService {
*/
R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType, String dataSource);
/**
* 按单个下游渠道范围异步拉上游全量详情(只拉范围内上游酒店,本地跳过)。
* @param partnerCode 下游伙伴编码(看渠道范围映射,不必是协议推送伙伴)
*/
R<SmHotelSyncLogEntity> startScopeDetailPullAsync(String partnerCode, String triggerType);
/**
* 按全部下游渠道范围合集异步拉上游全量详情(并集去重,本地跳过)。
*/
R<SmHotelSyncLogEntity> startScopeUnionDetailPullAsync(String triggerType);
/**
* 近窗详情:渠道范围合集(或指定伙伴)只拉未来 N 天房价,保持可售近窗新鲜。
* 不占用全量详情的 6h/每日次数配额。
* @param partnerCode 空则全部范围合集;非空则该伙伴范围
* @param days 近窗天数,null/≤0 用配置 near-detail-pull-days(建议 7 或 15)
*/
R<SmHotelSyncLogEntity> startNearScopeDetailPullAsync(String partnerCode, Integer days, String triggerType);
/**
* 渠道范围内可拉上游详情的酒店数预览(跳过本地)。
* @param partnerCode 空则按全部下游范围合集统计
*/
java.util.Map<String, Object> previewScopeDetailPull(String partnerCode);
/**
* 按下游范围映射异步全量推送(只推本地已落库数据,不再拉上游)。
* @param partnerCode 下游伙伴编码(对接配置中已启用)
@@ -115,4 +140,19 @@ public interface SmHotelSyncService {
Map<String, Object> jobRollLocalDaily();
Map<String, Object> jobRollLocalDaily(String triggerType);
/** XXL / 定时:上游渠道全量详情 */
Map<String, Object> jobDetailPull();
Map<String, Object> jobDetailPull(String triggerType);
/** XXL / 定时:渠道范围合集全量详情 */
Map<String, Object> jobScopeDetailPull();
Map<String, Object> jobScopeDetailPull(String triggerType);
/** XXL / 定时:渠道范围近窗详情(7/15 天) */
Map<String, Object> jobNearDetailPull();
Map<String, Object> jobNearDetailPull(String triggerType);
}
@@ -0,0 +1,244 @@
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.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.supermarket.entity.SmHotelAppEntryDeptEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelAppEntryDeptMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelAppEntryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
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 SmHotelAppEntryServiceImpl extends ServiceImpl<SmHotelAppEntryDeptMapper, SmHotelAppEntryDeptEntity>
implements SmHotelAppEntryService {
private final RemoteDeptService remoteDeptService;
@Override
public Page<SmHotelAppEntryDeptEntity> page(Page<SmHotelAppEntryDeptEntity> page,
SmHotelAppEntryDeptEntity query) {
Page<SmHotelAppEntryDeptEntity> result = baseMapper.selectPage(page,
Wrappers.<SmHotelAppEntryDeptEntity>lambdaQuery()
.eq(query != null && query.getDeptId() != null, SmHotelAppEntryDeptEntity::getDeptId,
query == null ? null : query.getDeptId())
.eq(query != null && StrUtil.isNotBlank(query.getStatus()), SmHotelAppEntryDeptEntity::getStatus,
query == null ? null : query.getStatus())
.orderByDesc(SmHotelAppEntryDeptEntity::getUpdateTime)
.orderByDesc(SmHotelAppEntryDeptEntity::getCreateTime));
fillDeptNames(result.getRecords());
return result;
}
@Override
public List<SmHotelAppEntryDeptEntity> listEnabled() {
List<SmHotelAppEntryDeptEntity> list = baseMapper.selectList(Wrappers.<SmHotelAppEntryDeptEntity>lambdaQuery()
.eq(SmHotelAppEntryDeptEntity::getStatus, "1")
.orderByAsc(SmHotelAppEntryDeptEntity::getDeptId));
fillDeptNames(list);
return list;
}
@Override
public List<Long> listEnabledDeptIds() {
return listEnabled().stream().map(SmHotelAppEntryDeptEntity::getDeptId).filter(Objects::nonNull).distinct()
.collect(Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> replaceEnabledDepts(List<Long> deptIds, String remark) {
Set<Long> want = new LinkedHashSet<>();
if (CollUtil.isNotEmpty(deptIds)) {
for (Long id : deptIds) {
if (id != null && id > 0) {
want.add(id);
}
}
}
List<SmHotelAppEntryDeptEntity> existing = baseMapper.selectList(Wrappers.emptyWrapper());
Map<Long, SmHotelAppEntryDeptEntity> byDept = new HashMap<>();
for (SmHotelAppEntryDeptEntity row : existing) {
if (row.getDeptId() != null) {
byDept.put(row.getDeptId(), row);
}
}
// 关掉不在本次勾选里的
for (SmHotelAppEntryDeptEntity row : existing) {
if (row.getDeptId() == null) {
continue;
}
if (!want.contains(row.getDeptId())) {
if (!"0".equals(row.getStatus())) {
row.setStatus("0");
baseMapper.updateById(row);
}
}
}
String rm = StrUtil.trim(remark);
for (Long deptId : want) {
SmHotelAppEntryDeptEntity row = byDept.get(deptId);
if (row == null) {
row = new SmHotelAppEntryDeptEntity();
row.setDeptId(deptId);
row.setStatus("1");
if (StrUtil.isNotBlank(rm)) {
row.setRemark(rm);
}
baseMapper.insert(row);
}
else {
boolean changed = false;
if (!"1".equals(row.getStatus())) {
row.setStatus("1");
changed = true;
}
if (StrUtil.isNotBlank(rm) && !rm.equals(row.getRemark())) {
row.setRemark(rm);
changed = true;
}
if (changed) {
baseMapper.updateById(row);
}
}
}
return R.ok(Boolean.TRUE);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveOne(SmHotelAppEntryDeptEntity entity) {
if (entity == null || entity.getDeptId() == null) {
return R.failed("请选择部门");
}
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelAppEntryDeptEntity>lambdaQuery()
.eq(SmHotelAppEntryDeptEntity::getDeptId, entity.getDeptId())
.eq(SmHotelAppEntryDeptEntity::getStatus, "1"));
if (cnt != null && cnt > 0) {
return R.failed("该部门已在可见列表中");
}
SmHotelAppEntryDeptEntity exist = baseMapper.selectOne(Wrappers.<SmHotelAppEntryDeptEntity>lambdaQuery()
.eq(SmHotelAppEntryDeptEntity::getDeptId, entity.getDeptId())
.last("limit 1"));
if (exist != null) {
exist.setStatus("1");
if (StrUtil.isNotBlank(entity.getRemark())) {
exist.setRemark(entity.getRemark());
}
return R.ok(baseMapper.updateById(exist) > 0);
}
if (StrUtil.isBlank(entity.getStatus())) {
entity.setStatus("1");
}
return R.ok(baseMapper.insert(entity) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> updateOne(SmHotelAppEntryDeptEntity entity) {
if (entity == null || entity.getId() == null) {
return R.failed("缺少主键");
}
return R.ok(baseMapper.updateById(entity) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeByIds(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return R.failed("请选择要删除的记录");
}
return R.ok(baseMapper.deleteBatchIds(ids) > 0);
}
@Override
public boolean isTravelVisibleForCurrentUser() {
List<Long> whitelist = listEnabledDeptIds();
if (CollUtil.isEmpty(whitelist)) {
return false;
}
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getDeptId() == null) {
return false;
}
Long userDeptId = user.getDeptId();
Set<Long> allow = new HashSet<>(whitelist);
if (allow.contains(userDeptId)) {
return true;
}
// 用户部门链(含自身与上级):命中任一白名单部门即可见(白名单部门的子部门用户)
try {
R<List<SysDept>> r = remoteDeptService.getParentDeptList(userDeptId);
if (r != null && r.isOk() && CollUtil.isNotEmpty(r.getData())) {
for (SysDept d : r.getData()) {
if (d != null && d.getDeptId() != null && allow.contains(d.getDeptId())) {
return true;
}
}
}
}
catch (Exception e) {
log.warn("|出行入口|查部门链失败|deptId={}|{}", userDeptId, e.getMessage());
}
return false;
}
@Override
public Map<String, Object> currentUserEntryVisible() {
boolean visible = isTravelVisibleForCurrentUser();
Map<String, Object> data = new LinkedHashMap<>(4);
data.put("travelVisible", visible);
data.put("orderHotelVisible", visible);
return data;
}
private void fillDeptNames(List<SmHotelAppEntryDeptEntity> records) {
if (CollUtil.isEmpty(records)) {
return;
}
List<Long> deptIds = records.stream().map(SmHotelAppEntryDeptEntity::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 (SmHotelAppEntryDeptEntity row : records) {
if (row.getDeptId() != null) {
row.setDeptName(nameById.getOrDefault(row.getDeptId(), String.valueOf(row.getDeptId())));
}
}
}
}
@@ -485,6 +485,27 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (StrUtil.isNotBlank(dto.getLocalDailyRollCron())) {
target.setLocalDailyRollCron(dto.getLocalDailyRollCron().trim());
}
if (dto.getDetailPullDailyEnabled() != null) {
target.setDetailPullDailyEnabled(dto.getDetailPullDailyEnabled());
}
if (StrUtil.isNotBlank(dto.getDetailPullDailyCron())) {
target.setDetailPullDailyCron(dto.getDetailPullDailyCron().trim());
}
if (dto.getScopeDetailPullDailyEnabled() != null) {
target.setScopeDetailPullDailyEnabled(dto.getScopeDetailPullDailyEnabled());
}
if (StrUtil.isNotBlank(dto.getScopeDetailPullDailyCron())) {
target.setScopeDetailPullDailyCron(dto.getScopeDetailPullDailyCron().trim());
}
if (dto.getNearDetailPullEnabled() != null) {
target.setNearDetailPullEnabled(dto.getNearDetailPullEnabled());
}
if (dto.getNearDetailPullDays() != null && dto.getNearDetailPullDays() > 0) {
target.setNearDetailPullDays(dto.getNearDetailPullDays());
}
if (StrUtil.isNotBlank(dto.getNearDetailPullCron())) {
target.setNearDetailPullCron(dto.getNearDetailPullCron().trim());
}
if (dto.getAutoImportNewHotels() != null) {
target.setAutoImportNewHotels(dto.getAutoImportNewHotels());
}
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -171,6 +172,9 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
return partners.stream().filter(StrUtil::isNotBlank).collect(Collectors.toList());
}
/**
* 同一下游伙伴全部启用规则取并集:指定酒店 ∪ 各部门(+区域)命中的上架酒店。
*/
@Override
public List<Long> listHotelIdsForDownstreamPartner(String partnerCode) {
String partner = normalizeCode(partnerCode);
@@ -180,13 +184,15 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
List<SmHotelChannelDeptEntity> scopes = baseMapper.selectList(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getStatus, "1")
.eq(SmHotelChannelDeptEntity::getChannel, partner));
// 忽略历史大小写差异,避免漏掉同伙伴另一条规则
.apply("lower(channel) = {0}", partner));
if (CollUtil.isEmpty(scopes)) {
return Collections.emptyList();
}
Set<Long> ids = new LinkedHashSet<>();
for (SmHotelChannelDeptEntity scope : scopes) {
String scopeType = SmHotelDownstreamScopeType.normalize(scope.getScopeType());
// 指定酒店:并入名单
if (SmHotelDownstreamScopeType.isHotel(scopeType)) {
if (StrUtil.isBlank(scope.getHotelIds())) {
continue;
@@ -199,6 +205,7 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
}
continue;
}
// 按部门(+可选区域):并入该部门上架酒店
if (scope.getDeptId() == null) {
continue;
}
@@ -236,6 +243,35 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
.collect(Collectors.toList());
}
@Override
public List<Long> listHotelIdsUnionAllDownstreamScopes() {
List<SmHotelChannelDeptEntity> scopes = baseMapper.selectList(Wrappers.<SmHotelChannelDeptEntity>lambdaQuery()
.select(SmHotelChannelDeptEntity::getChannel)
.eq(SmHotelChannelDeptEntity::getDirection, SmHotelChannelDirection.DOWNSTREAM)
.eq(SmHotelChannelDeptEntity::getStatus, "1"));
if (CollUtil.isEmpty(scopes)) {
return Collections.emptyList();
}
Set<String> partners = new LinkedHashSet<>();
for (SmHotelChannelDeptEntity scope : scopes) {
String partner = normalizeCode(scope.getChannel());
if (StrUtil.isNotBlank(partner)) {
partners.add(partner);
}
}
if (partners.isEmpty()) {
return Collections.emptyList();
}
Set<Long> ids = new LinkedHashSet<>();
for (String partner : partners) {
List<Long> part = listHotelIdsForDownstreamPartner(partner);
if (CollUtil.isNotEmpty(part)) {
ids.addAll(part);
}
}
return ids.isEmpty() ? Collections.emptyList() : new ArrayList<>(ids);
}
private void normalizeEntity(SmHotelChannelDeptEntity entity) {
entity.setChannel(normalizeCode(entity.getChannel()));
entity.setDirection(SmHotelChannelDirection.normalize(entity.getDirection()));
@@ -346,17 +382,12 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
return ids.isEmpty() ? null : String.join(",", ids);
}
/** 逗号列表包含判断(避免 split 大数组;两侧补逗号防子串误伤) */
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;
return ("," + hotelIds + ",").contains("," + hotelId + ",");
}
private String normalizeCode(String channel) {
@@ -399,41 +430,76 @@ public class SmHotelChannelDeptServiceImpl extends ServiceImpl<SmHotelChannelDep
}
}
/** 列表摘要:最多展示前若干家名称,避免上千家撑爆响应 */
private static final int HOTEL_NAME_PREVIEW = 8;
private void fillHotelNames(List<SmHotelChannelDeptEntity> records) {
if (records == null || records.isEmpty()) {
return;
}
Set<Long> allIds = new HashSet<>();
Map<Long, List<Long>> previewByRow = new HashMap<>();
Set<Long> loadIds = 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));
List<Long> ids = parseHotelIdList(row.getHotelIds());
if (ids.isEmpty()) {
continue;
}
List<Long> preview = ids.size() <= HOTEL_NAME_PREVIEW ? ids : ids.subList(0, HOTEL_NAME_PREVIEW);
previewByRow.put(row.getId(), preview);
loadIds.addAll(preview);
// 先占位数量,名称回填后再拼
row.setHotelNames("共" + ids.size() + "家");
}
if (loadIds.isEmpty()) {
return;
}
Map<Long, String> nameById = new HashMap<>();
List<Long> idList = new ArrayList<>(loadIds);
int batch = 500;
for (int i = 0; i < idList.size(); i += batch) {
List<Long> slice = idList.subList(i, Math.min(i + batch, idList.size()));
List<SmHotelEntity> hotels = hotelMapper.selectByIds(slice);
if (CollUtil.isEmpty(hotels)) {
continue;
}
for (SmHotelEntity h : hotels) {
if (h != null && h.getId() != null) {
nameById.put(h.getId(), StrUtil.blankToDefault(h.getHotelName(), String.valueOf(h.getId())));
}
}
}
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())) {
List<Long> preview = previewByRow.get(row.getId());
if (preview == null || preview.isEmpty()) {
continue;
}
List<String> names = Arrays.stream(row.getHotelIds().split(","))
.map(StrUtil::trim)
.filter(p -> p.matches("\\d+"))
.map(Long::valueOf)
List<Long> all = parseHotelIdList(row.getHotelIds());
String names = preview.stream()
.map(id -> nameById.getOrDefault(id, String.valueOf(id)))
.collect(Collectors.toList());
row.setHotelNames(String.join("、", names));
.collect(Collectors.joining("、"));
if (all.size() > preview.size()) {
row.setHotelNames(names + " 等共" + all.size() + "家");
}
else {
row.setHotelNames(names + "(" + all.size() + "家)");
}
}
}
private static List<Long> parseHotelIdList(String hotelIds) {
if (StrUtil.isBlank(hotelIds)) {
return Collections.emptyList();
}
List<Long> ids = new ArrayList<>();
for (String part : hotelIds.split(",")) {
String p = StrUtil.trim(part);
if (p.matches("\\d+")) {
ids.add(Long.valueOf(p));
}
}
return ids;
}
}
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -153,6 +154,24 @@ public class SmHotelDownstreamAppServiceImpl extends ServiceImpl<SmHotelDownstre
return StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey()));
}
@Override
public boolean isEnabledOpenAppPartner(String partnerCode) {
String code = StrUtil.trim(partnerCode);
if (StrUtil.isBlank(code)) {
return false;
}
List<SmHotelDownstreamAppEntity> apps = baseMapper.selectList(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.select(SmHotelDownstreamAppEntity::getId, SmHotelDownstreamAppEntity::getPartnerCode,
SmHotelDownstreamAppEntity::getAppKey)
.last("limit 500"));
if (CollUtil.isEmpty(apps)) {
return false;
}
return apps.stream().anyMatch(a -> code.equalsIgnoreCase(StrUtil.trim(a.getPartnerCode()))
|| code.equalsIgnoreCase(StrUtil.trim(a.getAppKey())));
}
private boolean existsAppKey(String appKey, Long excludeId) {
Long cnt = baseMapper.selectCount(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getAppKey, appKey)
@@ -7,9 +7,14 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.constant.SmHotelDownstreamProtocol;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.supermarket.constant.SmHotelStayDates;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamPushLogEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelConfigService;
import com.bsd.pjcloud.supermarket.service.SmHotelChannelDeptService;
@@ -24,6 +29,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -49,6 +55,8 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
private final SmHotelMapper hotelMapper;
private final SmHotelDailyMapper dailyMapper;
private final SmHotelMidService midService;
private final SmHotelSyncService syncService;
@@ -57,12 +65,13 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
public SmHotelDownstreamAutoPushServiceImpl(HotelDownstreamProperties yamlDefaults,
SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService,
SmHotelMapper hotelMapper, @Lazy SmHotelMidService midService, @Lazy SmHotelSyncService syncService,
SmHotelDownstreamPushLogService downstreamPushLogService) {
SmHotelMapper hotelMapper, SmHotelDailyMapper dailyMapper, @Lazy SmHotelMidService midService,
@Lazy SmHotelSyncService syncService, SmHotelDownstreamPushLogService downstreamPushLogService) {
this.yamlDefaults = yamlDefaults;
this.channelConfigService = channelConfigService;
this.channelDeptService = channelDeptService;
this.hotelMapper = hotelMapper;
this.dailyMapper = dailyMapper;
this.midService = midService;
this.syncService = syncService;
this.downstreamPushLogService = downstreamPushLogService;
@@ -70,6 +79,9 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
private final ConcurrentHashMap<Long, ScheduledFuture<?>> pendingByHotel = new ConcurrentHashMap<>();
/** 验价失败补偿:hotelId|partner 防抖,避免下游短时间连验刷爆上游/推送 */
private final ConcurrentHashMap<String, ScheduledFuture<?>> pendingCheckFail = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "sm-hotel-downstream-auto-push");
t.setDaemon(true);
@@ -98,6 +110,125 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
}
}
@Override
public void scheduleRefreshAndPushAfterCheckFail(Long hotelId, String partnerCode, LocalDate checkIn,
LocalDate checkOut, Long productId) {
if (hotelId == null) {
return;
}
String partner = StrUtil.trim(partnerCode);
String key = hotelId + "|" + StrUtil.blankToDefault(partner, "*") + "|"
+ (productId == null ? "-" : productId);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = 1L;
}
Long tid = tenantId;
LocalDate in = checkIn == null ? LocalDate.now() : checkIn;
LocalDate out = checkOut == null ? in.plusDays(1) : checkOut;
pendingCheckFail.compute(key, (k, old) -> {
if (old != null) {
old.cancel(false);
}
// 短防抖:先回验价响应,再异步拉/推,合并下游连验
return scheduler.schedule(() -> {
pendingCheckFail.remove(k);
try {
TenantBroker.runAs(tid, t -> doRefreshAndPushAfterCheckFail(hotelId, partner, in, out, productId));
}
catch (Exception e) {
log.warn("|{}|验价失败补偿异常|hotelId={}|partner={}|err={}|", TAG, hotelId, partner,
e.getMessage());
}
}, 2, TimeUnit.SECONDS);
});
log.info("|{}|已安排验价失败补偿|hotelId={}|partner={}|productId={}|{}~{}|", TAG, hotelId, partner, productId,
in, out);
}
/**
* 上游:按入住区间重拉价态(至少覆盖入住日,不足 7 天补到 7 天);本地:跳过拉取。
* 若带 productId:重拉后再强制关该产品入住日(以 checkAvailability 售空为准)。
* 随后向指定伙伴直推;无伙伴则走通用自动推。
*/
private void doRefreshAndPushAfterCheckFail(Long hotelId, String partner, LocalDate checkIn, LocalDate checkOut,
Long productId) {
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
if (hotel == null) {
return;
}
boolean local = SmHotelDataSource.isLocal(hotel.getSheetName());
if (!local) {
LocalDate start = checkIn;
LocalDate endExclusive = checkOut != null && checkOut.isAfter(checkIn) ? checkOut : checkIn.plusDays(1);
// 验价失败常因价态过期:至少拉 7 天,最多 30 天,避免一次过重
if (endExclusive.isBefore(start.plusDays(7))) {
endExclusive = start.plusDays(7);
}
if (endExclusive.isAfter(start.plusDays(30))) {
endExclusive = start.plusDays(30);
}
try {
if (SmHotelDataSource.isWritableByChannel(hotel.getSheetName(), SmHotelDataSource.SHANGYOU)
|| StrUtil.isBlank(SmHotelDataSource.normalize(hotel.getSheetName()))) {
R<Map<String, Object>> pulled = midService.pullPriceRange(hotelId, start, endExclusive);
log.info("|{}|验价失败已重拉上游|hotelId={}|partner={}|{}~{}|ok={}|msg={}|", TAG, hotelId, partner,
start, endExclusive, pulled != null && pulled.isOk(),
pulled == null ? null : pulled.getMsg());
}
else {
log.info("|{}|验价失败跳过重拉(非商游可写来源)|hotelId={}|ds={}|", TAG, hotelId,
hotel.getSheetName());
}
}
catch (Exception e) {
log.warn("|{}|验价失败重拉上游失败,仍尝试推本地快照|hotelId={}|err={}|", TAG, hotelId, e.getMessage());
}
}
// 上游可订已判售空时,价态列表常仍有房:以验价结果为准关该产品入住日,避免再推开房
if (productId != null && SmHotelStayDates.isValid(checkIn, checkOut)) {
int closed = forceCloseProductStayDates(productId, checkIn, checkOut);
log.info("|{}|验价失败强制关房|hotelId={}|productId={}|{}~{}|closedDays={}|", TAG, hotelId, productId,
checkIn, checkOut, closed);
}
if (StrUtil.isNotBlank(partner)) {
long t0 = System.currentTimeMillis();
try {
R<Map<String, Object>> r = midService.pushToDownstream(hotelId, partner);
log.info("|{}|验价失败已直推伙伴|hotelId={}|partner={}|ok={}|costMs={}|msg={}|", TAG, hotelId, partner,
r != null && r.isOk(), System.currentTimeMillis() - t0, r == null ? null : r.getMsg());
}
catch (Exception e) {
log.warn("|{}|验价失败直推伙伴失败|hotelId={}|partner={}|err={}|", TAG, hotelId, partner, e.getMessage());
}
return;
}
// 无指定伙伴:走原有命中范围自动推
scheduleAfterHotelChanged(hotelId);
}
/** 把产品在占用日历日标为关房(dailyStatus=0),推下游时按满房 */
private int forceCloseProductStayDates(Long productId, LocalDate checkIn, LocalDate checkOut) {
int n = 0;
for (LocalDate d : SmHotelStayDates.occupyDates(checkIn, checkOut)) {
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
if (row == null) {
continue;
}
Integer st = row.getDailyStatus();
if (st != null && st == 0) {
continue;
}
row.setDailyStatus(0);
dailyMapper.updateById(row);
n++;
}
return n;
}
@Override
public void closeProductsOnMatchedPartners(Long hotelId, String productCode, String roomTypeCode) {
if (hotelId == null || StrUtil.isBlank(productCode)) {
@@ -347,6 +478,18 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
@PreDestroy
public void shutdown() {
pendingCheckFail.values().forEach(f -> {
if (f != null) {
f.cancel(false);
}
});
pendingCheckFail.clear();
pendingByHotel.values().forEach(f -> {
if (f != null) {
f.cancel(false);
}
});
pendingByHotel.clear();
scheduler.shutdownNow();
}
}
@@ -136,6 +136,9 @@ public class SmHotelDownstreamInboundRetryServiceImpl implements SmHotelDownstre
JSONObject payload = JSONUtil.parseObj(rawBody);
Map<String, Object> resp = new LinkedHashMap<>(4);
if ("inbound_check".equals(action)) {
if (StrUtil.isNotBlank(partner)) {
payload.set("partnerCode", partner);
}
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(payload);
if (r != null && r.getCode() == 0) {
resp.put("code", 200);
@@ -184,6 +184,9 @@ public class SmHotelHuanqiOrderServiceImpl implements SmHotelHuanqiOrderService
checkBody.put("checkIn", order.getCheckInDate() == null ? null : order.getCheckInDate().toString());
checkBody.put("checkOut", order.getCheckOutDate() == null ? null : order.getCheckOutDate().toString());
checkBody.put("roomCnt", order.getCount() == null ? 1 : order.getCount());
if (StrUtil.isNotBlank(partnerCode)) {
checkBody.put("partnerCode", partnerCode);
}
// 推单履约预检不带 price:环启 price 是结算价,与本地日历卖价口径不同,避免误拦
R<Map<String, Object>> check = hotelOrderService.checkBookableForDownstream(checkBody);
boolean bookable = check != null && check.getCode() == 0 && check.getData() != null
@@ -83,6 +83,9 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
public Map<String, Object> check(String partnerCode, JSONObject data) {
Map<String, Object> outer = ok200();
Map<String, Object> checkBody = toCheckBody(data);
if (StrUtil.isNotBlank(partnerCode)) {
checkBody.put("partnerCode", partnerCode);
}
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(checkBody);
Map<String, Object> inner = new LinkedHashMap<>(8);
List<Map<String, Object>> priceList = new ArrayList<>();
@@ -106,7 +109,12 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
row.put("dateStr", day.get("date"));
row.put("price", day.get("sellPrice"));
Object remain = day.get("remain");
row.put("roomNum", remain == null ? "0" : String.valueOf(remain));
// 不可订时勿把本地剩余房量回给慧订(易造成「校验售空却仍显示有房」)
String roomNum = "0";
if (bookable) {
roomNum = remain == null ? "0" : String.valueOf(remain);
}
row.put("roomNum", roomNum);
row.put("supplierRateTypeCode", productId);
priceList.add(row);
int rem = 0;
@@ -175,6 +183,9 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
checkBody.put("checkOut", mapped.checkOut.toString());
checkBody.put("roomCnt", mapped.quantity);
checkBody.put("price", mapped.orderAmount);
if (StrUtil.isNotBlank(partnerCode)) {
checkBody.put("partnerCode", partnerCode);
}
R<Map<String, Object>> check = hotelOrderService.checkBookableForDownstream(checkBody);
boolean bookable = check != null && check.getCode() == 0 && check.getData() != null
&& Boolean.TRUE.equals(check.getData().get("bookable"));
@@ -404,14 +415,27 @@ public class SmHotelHuidingOrderServiceImpl implements SmHotelHuidingOrderServic
log.warn("|{}|跳过 operationOrder,非慧订伙伴|partner={}|", TAG, partnerCode);
return;
}
// 确认回写前先查上游详情,拿到酒店单号再推给慧订客人核对
if (operationType == OP_CONFIRM) {
hotelOrderService.refreshUpstreamSnapshot(order);
SmHotelOrderEntity latest = orderMapper.selectById(order.getId());
if (latest != null) {
order = latest;
}
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("key", HuidingDownstreamHotelAdapter.resolveKey(props));
body.put("supplierId", props.getSupplierId());
body.put("childOrderCode", order.getChannelNo());
body.put("supplierOrderCode", order.getId() == null ? null : String.valueOf(order.getId()));
body.put("operationType", operationType);
if (StrUtil.isNotBlank(order.getPlatformNo())) {
body.put("hotelConfirmCode", order.getPlatformNo());
// 优先酒店确认单号(上游详情 orderId);无则回落 platformNo
String confirm = hotelOrderService.resolveHotelConfirmNo(order);
if (StrUtil.isBlank(confirm)) {
confirm = order.getPlatformNo();
}
if (StrUtil.isNotBlank(confirm)) {
body.put("hotelConfirmCode", confirm);
}
if (operationType == OP_CONFIRM && order.getSellAmount() != null) {
body.put("totalPrice", order.getSellAmount());
@@ -0,0 +1,637 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelMgrPushLogEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushConfigMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMgrPushLogMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelOrderMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelMgrPushService;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppMessageService;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
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;
/**
* 酒店管理推送实现:独立旁路 eventCode,不影响有房通知。
* <p>负责人 = 酒店所属部门(本部门,不含下级)下、岗位编码 TEAM_LEADER(部门负责人)的用户。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmHotelMgrPushServiceImpl implements SmHotelMgrPushService {
private static final String ENABLED = "1";
private static final String EVENT_NEW_ORDER = "HOTEL_MGR_NEW_ORDER";
private static final String EVENT_CALENDAR = "HOTEL_MGR_CALENDAR_REMIND";
private static final String DEFAULT_NEW_TITLE = "酒店新订单";
private static final String DEFAULT_NEW_CONTENT = "您有新的酒店订单,请及时处理";
private static final String DEFAULT_CAL_TITLE = "酒店日历提醒";
private static final String DEFAULT_CAL_CONTENT = "请修改酒店日历库存和价格";
private static final String DEFAULT_HOURS = "7,12";
private final SmHotelMgrPushConfigMapper configMapper;
private final SmHotelMgrPushLogMapper pushLogMapper;
private final SmHotelOrderMapper orderMapper;
private final SmHotelMapper hotelMapper;
private final RemoteDeptService remoteDeptService;
private final RemoteUserService remoteUserService;
private final RemoterThirdAppMessageService remoteAppMessageService;
@Override
@Transactional(rollbackFor = Exception.class)
public SmHotelMgrPushConfigEntity getOrCreateConfig() {
SmHotelMgrPushConfigEntity exists = configMapper.selectOne(Wrappers.<SmHotelMgrPushConfigEntity>lambdaQuery()
.last("limit 1"));
if (exists != null) {
return exists;
}
SmHotelMgrPushConfigEntity row = newDefaults();
configMapper.insert(row);
return row;
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> saveConfig(SmHotelMgrPushConfigEntity body) {
if (body == null) {
return R.failed("参数为空");
}
SmHotelMgrPushConfigEntity current = getOrCreateConfig();
current.setNewOrderEnabled(normalizeFlag(body.getNewOrderEnabled(), current.getNewOrderEnabled()));
current.setNewOrderTitle(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderTitle()), DEFAULT_NEW_TITLE));
current.setNewOrderContent(StrUtil.blankToDefault(StrUtil.trim(body.getNewOrderContent()), DEFAULT_NEW_CONTENT));
current.setCalendarRemindEnabled(
normalizeFlag(body.getCalendarRemindEnabled(), current.getCalendarRemindEnabled()));
current.setCalendarRemindTitle(
StrUtil.blankToDefault(StrUtil.trim(body.getCalendarRemindTitle()), DEFAULT_CAL_TITLE));
current.setCalendarRemindContent(
StrUtil.blankToDefault(StrUtil.trim(body.getCalendarRemindContent()), DEFAULT_CAL_CONTENT));
String hours = StrUtil.blankToDefault(StrUtil.trim(body.getCalendarRemindHours()), DEFAULT_HOURS);
if (parseHours(hours).isEmpty()) {
return R.failed("日历提醒小时无效,请填写如 7,12");
}
current.setCalendarRemindHours(hours);
current.setRemark(StrUtil.trim(body.getRemark()));
configMapper.updateById(current);
return R.ok(Boolean.TRUE);
}
@Override
public Page<SmHotelMgrPushLogEntity> pageLogs(Page<SmHotelMgrPushLogEntity> page, String pushType) {
return pushLogMapper.selectPage(page, Wrappers.<SmHotelMgrPushLogEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(pushType), SmHotelMgrPushLogEntity::getPushType, StrUtil.trim(pushType))
.orderByDesc(SmHotelMgrPushLogEntity::getCreateTime)
.orderByDesc(SmHotelMgrPushLogEntity::getId));
}
@Override
public void notifyNewOrder(Long orderId) {
doPushNewOrder(orderId, false);
}
@Override
public R<Integer> pushNewOrderManual(Long orderId, String channelNo) {
Long resolvedId = orderId;
if (resolvedId == null && StrUtil.isNotBlank(channelNo)) {
SmHotelOrderEntity byNo = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getChannelNo, StrUtil.trim(channelNo))
.orderByDesc(SmHotelOrderEntity::getId)
.last("limit 1"));
if (byNo == null) {
return R.failed("未找到渠道单号对应订单:" + channelNo);
}
resolvedId = byNo.getId();
}
if (resolvedId == null) {
SmHotelOrderEntity latest = orderMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.orderByDesc(SmHotelOrderEntity::getId)
.last("limit 1"));
if (latest == null) {
return R.failed("暂无酒店订单可推送");
}
resolvedId = latest.getId();
}
PushResult result = doPushNewOrder(resolvedId, true);
if (!result.ok) {
return R.failed(result.message);
}
return R.ok(result.successCount, result.message);
}
@Override
public int runCalendarRemind(int hour, boolean force) {
return doCalendarRemind(hour, force).successCount;
}
@Override
public R<Integer> pushCalendarRemindManual(Integer hour) {
int h = hour == null ? LocalDateTime.now().getHour() : hour;
PushResult result = doCalendarRemind(h, true);
if (!result.ok) {
return R.failed(result.message);
}
return R.ok(result.successCount, result.message);
}
private PushResult doPushNewOrder(Long orderId, boolean force) {
if (orderId == null) {
return PushResult.fail("订单ID为空");
}
try {
SmHotelMgrPushConfigEntity cfg = getOrCreateConfig();
if (!force && !ENABLED.equals(cfg.getNewOrderEnabled())) {
return PushResult.skip("新订单推送已关闭");
}
SmHotelOrderEntity order = orderMapper.selectById(orderId);
if (order == null) {
return PushResult.fail("订单不存在:" + orderId);
}
if ("CREATED".equals(order.getOrderStatus()) && "UNPAID".equals(order.getPayStatus())) {
return force ? PushResult.fail("订单待支付,暂不推送") : PushResult.skip("待支付跳过");
}
if ("UPSTREAM_FAIL".equals(order.getOrderStatus()) || "CANCELLED".equals(order.getOrderStatus())) {
return force ? PushResult.fail("订单状态为失败/已取消,不推送") : PushResult.skip("状态跳过");
}
SmHotelEntity hotel = order.getHotelId() == null ? null : hotelMapper.selectById(order.getHotelId());
// 优先酒店部门,其次订单上冗余部门
Long deptId = hotel != null ? hotel.getDeptId() : null;
if (deptId == null) {
deptId = order.getDeptId();
}
String hotelName = hotel == null ? "酒店"
: StrUtil.blankToDefault(hotel.getHotelName(), "酒店");
String title = StrUtil.blankToDefault(cfg.getNewOrderTitle(), DEFAULT_NEW_TITLE);
String contentTpl = StrUtil.blankToDefault(cfg.getNewOrderContent(), DEFAULT_NEW_CONTENT);
String trigger = force ? SmHotelMgrPushLogEntity.TRIGGER_MANUAL : SmHotelMgrPushLogEntity.TRIGGER_AUTO;
if (deptId == null) {
String tip = "酒店未绑定部门(hotelId=" + order.getHotelId()
+ ")。请在酒店信息里配置所属部门后再推。";
log.info("|酒店管理推送|新订单跳过无部门|orderId={}|hotelId={}|", orderId, order.getHotelId());
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
order.getHotelId(), hotelName, List.of(), List.of(), 0, 0, title, null, tip);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
List<Long> leaders = resolveDeptLeaders(deptId);
if (CollUtil.isEmpty(leaders)) {
String tip = "部门 " + deptId + " 下没有岗位=部门负责人(TEAM_LEADER)的用户(仅本部门,不含下级)。"
+ "请到用户管理确认:用户所属部门=该酒店部门,且岗位勾选「部门负责人」。";
log.info("|酒店管理推送|新订单无负责人|orderId={}|deptId={}|hotelId={}|", orderId, deptId,
order.getHotelId());
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
order.getHotelId(), hotelName, List.of(deptId), List.of(), 0, 0, title, null, tip);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
Long tenantId = resolveTenantId(order.getTenantId(), hotel == null ? null : hotel.getTenantId());
String channelNoVal = StrUtil.blankToDefault(order.getChannelNo(), String.valueOf(orderId));
String content = contentTpl.replace("{hotelName}", hotelName).replace("{channelNo}", channelNoVal);
if (!content.contains(hotelName) && !contentTpl.contains("{hotelName}")) {
content = hotelName + ":" + content;
}
Map<String, Object> payload = new HashMap<>(8);
payload.put("orderId", orderId);
payload.put("hotelId", order.getHotelId());
payload.put("channelNo", channelNoVal);
payload.put("deptId", deptId);
payload.put("force", force);
Map<String, Object> linkParams = new HashMap<>(4);
linkParams.put("orderId", String.valueOf(orderId));
String eventPrefix = force
? ("HOTEL_MGR_ORDER_F:" + orderId + ":" + System.currentTimeMillis())
: ("HOTEL_MGR_ORDER:" + orderId);
int ok = 0;
List<String> failHints = new ArrayList<>();
for (Long userId : leaders) {
if (userId == null) {
continue;
}
HotelManagerPushEventDto dto = new HotelManagerPushEventDto();
dto.setEventId(eventPrefix + ":" + userId);
dto.setEventCode(EVENT_NEW_ORDER);
dto.setTenantId(tenantId);
dto.setRecipientUserId(userId);
dto.setHotelId(order.getHotelId());
dto.setSourceBizId(String.valueOf(orderId));
dto.setSourceName(hotelName);
dto.setTitle(title);
dto.setContent(content);
dto.setPayloadJson(JSONUtil.toJsonStr(payload));
dto.setDeepLinkPath("/pages/hotel-manage/orders");
dto.setDeepLinkParamsJson(JSONUtil.toJsonStr(linkParams));
dto.setOccurredTime(LocalDateTime.now());
String err = pushOne(dto);
if (err == null) {
ok++;
}
else if (failHints.size() < 3) {
failHints.add("userId=" + userId + ":" + err);
}
}
int fail = Math.max(0, leaders.size() - ok);
log.info("|酒店管理推送|新订单完成|orderId={}|deptId={}|leaders={}|ok={}|force={}|leadersIds={}|",
orderId, deptId, leaders.size(), ok, force, leaders);
if (ok == 0) {
String tip = "找到负责人 " + leaders.size() + " 人(deptId=" + deptId + ", userIds=" + leaders
+ "),但消息中心投递全部失败。"
+ (failHints.isEmpty() ? "请确认 thirdparty 已重启并包含酒店管理推送接口。"
: ("原因:" + String.join(";", failHints)));
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
order.getHotelId(), hotelName, List.of(deptId), leaders, 0, fail, title, content, tip);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
String msg = "已推送成功 " + ok + "/" + leaders.size() + " 人(订单ID=" + orderId + ",deptId=" + deptId
+ ",负责人=" + leaders + ")";
savePushLog(SmHotelMgrPushLogEntity.TYPE_NEW_ORDER, trigger, String.valueOf(orderId),
order.getHotelId(), hotelName, List.of(deptId), leaders, ok, fail, title, content, msg);
return PushResult.success(ok, leaders.size(), msg);
}
catch (Exception e) {
log.warn("|酒店管理推送|新订单异常|orderId={}|err={}|", orderId, e.getMessage(), e);
return force ? PushResult.fail("推送异常:" + e.getMessage()) : PushResult.skip(e.getMessage());
}
}
private PushResult doCalendarRemind(int hour, boolean force) {
SmHotelMgrPushConfigEntity cfg = getOrCreateConfig();
if (!ENABLED.equals(cfg.getCalendarRemindEnabled())) {
return force ? PushResult.fail("日历提醒已关闭") : PushResult.skip("已关闭");
}
Set<Integer> hours = parseHours(cfg.getCalendarRemindHours());
if (!force && !hours.contains(hour)) {
return PushResult.skip("当前小时不在推送列表");
}
List<SmHotelEntity> hotels = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getDeptId, SmHotelEntity::getTenantId)
.isNotNull(SmHotelEntity::getDeptId));
if (CollUtil.isEmpty(hotels)) {
return force ? PushResult.fail("没有绑定部门的酒店,无法推送") : PushResult.skip("无酒店部门");
}
Set<Long> deptIds = hotels.stream()
.map(SmHotelEntity::getDeptId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
String trigger = force ? SmHotelMgrPushLogEntity.TRIGGER_MANUAL : SmHotelMgrPushLogEntity.TRIGGER_AUTO;
String day = LocalDate.now().toString();
String title = StrUtil.blankToDefault(cfg.getCalendarRemindTitle(), DEFAULT_CAL_TITLE);
String content = StrUtil.blankToDefault(cfg.getCalendarRemindContent(), DEFAULT_CAL_CONTENT);
Set<Long> leaders = new LinkedHashSet<>();
List<Long> deptsWithLeader = new ArrayList<>();
for (Long deptId : deptIds) {
List<Long> ids = resolveDeptLeaders(deptId);
if (CollUtil.isNotEmpty(ids)) {
leaders.addAll(ids);
deptsWithLeader.add(deptId);
}
}
if (leaders.isEmpty()) {
String tip = "扫描到 " + deptIds.size() + " 个酒店部门,但都没有岗位=部门负责人(TEAM_LEADER)的用户。"
+ "部门示例:" + deptIds.stream().limit(5).map(String::valueOf).collect(Collectors.joining(","));
log.info("|酒店管理推送|日历提醒无负责人|hour={}|deptCount={}|", hour, deptIds.size());
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
new ArrayList<>(deptIds), List.of(), 0, 0, title, content, tip);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
Long tenantId = resolveTenantId(null, hotels.get(0).getTenantId());
Map<String, Object> payload = new HashMap<>(4);
payload.put("hour", hour);
payload.put("day", day);
payload.put("force", force);
String eventPrefix = force
? ("HOTEL_MGR_CAL_F:" + day + ":" + hour + ":" + System.currentTimeMillis())
: ("HOTEL_MGR_CAL:" + day + ":" + hour);
int ok = 0;
List<String> failHints = new ArrayList<>();
for (Long userId : leaders) {
if (userId == null) {
continue;
}
HotelManagerPushEventDto dto = new HotelManagerPushEventDto();
dto.setEventId(eventPrefix + ":" + userId);
dto.setEventCode(EVENT_CALENDAR);
dto.setTenantId(tenantId);
dto.setRecipientUserId(userId);
dto.setSourceBizId(day + "-" + hour);
dto.setSourceName("酒店日历");
dto.setTitle(title);
dto.setContent(content);
dto.setPayloadJson(JSONUtil.toJsonStr(payload));
dto.setDeepLinkPath("/pages/hotel-manage/list");
dto.setOccurredTime(LocalDateTime.now());
String err = pushOne(dto);
if (err == null) {
ok++;
}
else if (failHints.size() < 3) {
failHints.add("userId=" + userId + ":" + err);
}
}
int fail = Math.max(0, leaders.size() - ok);
log.info("|酒店管理推送|日历提醒完成|hour={}|deptCount={}|leaders={}|ok={}|ids={}|", hour, deptIds.size(),
leaders.size(), ok, leaders);
List<Long> logDepts = deptsWithLeader.isEmpty() ? new ArrayList<>(deptIds) : deptsWithLeader;
if (ok == 0) {
String tip = "找到负责人 " + leaders.size() + " 人,但投递全部失败。"
+ (failHints.isEmpty() ? "请确认 thirdparty 已重启。" : ("原因:" + String.join(";", failHints)));
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
logDepts, new ArrayList<>(leaders), 0, fail, title, content, tip);
return force ? PushResult.fail(tip) : PushResult.skip(tip);
}
String msg = "日历提醒成功 " + ok + "/" + leaders.size() + " 人(负责人=" + leaders + ")";
savePushLog(SmHotelMgrPushLogEntity.TYPE_CALENDAR, trigger, day + "-" + hour, null, null,
logDepts, new ArrayList<>(leaders), ok, fail, title, content, msg);
return PushResult.success(ok, leaders.size(), msg);
}
private void savePushLog(String pushType, String triggerType, String bizId, Long hotelId, String hotelName,
List<Long> deptIds, List<Long> userIds, int success, int fail, String title, String content,
String resultMsg) {
try {
SmHotelMgrPushLogEntity row = new SmHotelMgrPushLogEntity();
row.setPushType(pushType);
row.setTriggerType(triggerType);
row.setBizId(bizId);
row.setHotelId(hotelId);
row.setHotelName(hotelName);
row.setDeptIds(joinLongs(deptIds));
row.setDeptNames(resolveDeptNames(deptIds));
row.setRecipientUserIds(joinLongs(userIds));
row.setRecipientNames(resolveUserNames(userIds));
row.setSuccessCount(success);
row.setFailCount(fail);
row.setTitle(title);
row.setContent(StrUtil.maxLength(content, 500));
row.setResultMsg(StrUtil.maxLength(resultMsg, 900));
if (success > 0 && fail <= 0) {
row.setStatus(SmHotelMgrPushLogEntity.STATUS_OK);
}
else if (success > 0) {
row.setStatus(SmHotelMgrPushLogEntity.STATUS_PARTIAL);
}
else {
row.setStatus(SmHotelMgrPushLogEntity.STATUS_FAIL);
}
pushLogMapper.insert(row);
}
catch (Exception e) {
log.warn("|酒店管理推送|写推送记录失败|type={}|bizId={}|err={}|", pushType, bizId, e.getMessage());
}
}
private String resolveDeptNames(List<Long> deptIds) {
if (CollUtil.isEmpty(deptIds)) {
return "";
}
try {
R<Map<Long, SysDept>> resp = remoteDeptService.getDeptByDeptIds(deptIds);
Map<Long, SysDept> map = resp != null && resp.isOk() ? resp.getData() : null;
List<String> names = new ArrayList<>();
for (Long id : deptIds) {
if (id == null) {
continue;
}
SysDept d = map == null ? null : map.get(id);
names.add(d != null && StrUtil.isNotBlank(d.getName()) ? d.getName() : String.valueOf(id));
}
return String.join("、", names);
}
catch (Exception e) {
return joinLongs(deptIds);
}
}
private String resolveUserNames(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return "";
}
try {
R<List<SysUser>> resp = remoteUserService.getInnerUserById(userIds);
List<SysUser> users = resp != null && resp.isOk() ? resp.getData() : null;
Map<Long, SysUser> map = new HashMap<>();
if (CollUtil.isNotEmpty(users)) {
for (SysUser u : users) {
if (u != null && u.getUserId() != null) {
map.put(u.getUserId(), u);
}
}
}
List<String> names = new ArrayList<>();
for (Long id : userIds) {
if (id == null) {
continue;
}
SysUser u = map.get(id);
if (u == null) {
names.add(String.valueOf(id));
continue;
}
String n = StrUtil.blankToDefault(u.getName(),
StrUtil.blankToDefault(u.getNickname(), u.getUsername()));
names.add(StrUtil.blankToDefault(n, String.valueOf(id)));
}
return String.join("、", names);
}
catch (Exception e) {
return joinLongs(userIds);
}
}
private static String joinLongs(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return "";
}
return ids.stream().filter(Objects::nonNull).map(String::valueOf).collect(Collectors.joining(","));
}
/** @return null=成功;非空=失败原因 */
private String pushOne(HotelManagerPushEventDto dto) {
try {
R<Boolean> resp = remoteAppMessageService.receiveHotelManagerPushEvent(dto);
if (resp == null) {
return "Feign返回null";
}
if (!resp.isOk() || !Boolean.TRUE.equals(resp.getData())) {
return StrUtil.blankToDefault(resp.getMsg(), "code=" + resp.getCode());
}
return null;
}
catch (Exception e) {
log.warn("|酒店管理推送|Feign异常|eventId={}|err={}|", dto.getEventId(), e.getMessage());
return e.getMessage();
}
}
/**
* 本部门负责人(不含下级):upms listDeptLeader,岗位编码 TEAM_LEADER。
* Feign 反序列化可能把 id 解成 Integer,这里统一转 Long。
*/
private List<Long> resolveDeptLeaders(Long deptId) {
if (deptId == null) {
return List.of();
}
try {
R<List<Long>> resp = remoteDeptService.getAllDeptLeader(deptId);
if (resp == null || !resp.isOk()) {
log.warn("|酒店管理推送|查负责人接口失败|deptId={}|code={}|msg={}|", deptId,
resp == null ? null : resp.getCode(), resp == null ? "null" : resp.getMsg());
return List.of();
}
List<?> raw = resp.getData();
if (CollUtil.isEmpty(raw)) {
log.info("|酒店管理推送|部门无负责人|deptId={}|", deptId);
return List.of();
}
List<Long> ids = new ArrayList<>(raw.size());
for (Object o : raw) {
Long id = toLong(o);
if (id != null) {
ids.add(id);
}
}
return ids.stream().distinct().toList();
}
catch (Exception e) {
log.warn("|酒店管理推送|查负责人异常|deptId={}|err={}|", deptId, e.getMessage());
return List.of();
}
}
private static Long toLong(Object o) {
if (o == null) {
return null;
}
if (o instanceof Number n) {
return n.longValue();
}
try {
return Long.valueOf(String.valueOf(o));
}
catch (Exception e) {
return null;
}
}
private static SmHotelMgrPushConfigEntity newDefaults() {
SmHotelMgrPushConfigEntity row = new SmHotelMgrPushConfigEntity();
row.setNewOrderEnabled(ENABLED);
row.setNewOrderTitle(DEFAULT_NEW_TITLE);
row.setNewOrderContent(DEFAULT_NEW_CONTENT);
row.setCalendarRemindEnabled(ENABLED);
row.setCalendarRemindTitle(DEFAULT_CAL_TITLE);
row.setCalendarRemindContent(DEFAULT_CAL_CONTENT);
row.setCalendarRemindHours(DEFAULT_HOURS);
return row;
}
private static String normalizeFlag(String raw, String fallback) {
String v = StrUtil.trim(raw);
if (ENABLED.equals(v) || "0".equals(v)) {
return v;
}
return StrUtil.blankToDefault(fallback, ENABLED);
}
private static Set<Integer> parseHours(String raw) {
Set<Integer> set = new HashSet<>();
if (StrUtil.isBlank(raw)) {
return set;
}
for (String part : raw.split(",")) {
String p = StrUtil.trim(part);
if (StrUtil.isBlank(p)) {
continue;
}
try {
int h = Integer.parseInt(p);
if (h >= 0 && h <= 23) {
set.add(h);
}
}
catch (NumberFormatException ignored) {
// skip
}
}
return set;
}
private static Long resolveTenantId(Long a, Long b) {
if (a != null) {
return a;
}
if (b != null) {
return b;
}
Long ctx = TenantContextHolder.getTenantId();
return ctx == null ? 1L : ctx;
}
private static final class PushResult {
private final boolean ok;
private final int successCount;
private final int leaderCount;
private final String message;
private PushResult(boolean ok, int successCount, int leaderCount, String message) {
this.ok = ok;
this.successCount = successCount;
this.leaderCount = leaderCount;
this.message = message;
}
static PushResult success(int success, int leaders, String msg) {
return new PushResult(true, success, leaders, msg);
}
static PushResult fail(String msg) {
return new PushResult(false, 0, 0, msg);
}
/** 自动触发路径静默跳过 */
static PushResult skip(String msg) {
return new PushResult(true, 0, 0, msg);
}
}
}
@@ -35,10 +35,10 @@ import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.util.SmHotelMerchantScopeHelper;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
@@ -122,6 +122,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
/** 按上游渠道解析取消规则(商游/后续其它家) */
private final HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry;
private final SmHotelMerchantScopeHelper hotelMerchantScopeHelper;
public SmHotelMidServiceImpl(ShangyouHotelClient upstreamClient, ShangyouHotelProperties upstreamProperties,
DownstreamHotelClient downstreamClient, HotelDownstreamProperties downstreamProperties,
SmHotelPriceConfigService priceConfigService, SmHotelChannelConfigService channelConfigService,
@@ -132,7 +134,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
SmHotelRoomMapper roomMapper, SmHotelProductMapper productMapper, SmHotelDailyMapper dailyMapper,
StringRedisTemplate stringRedisTemplate, ShengnuoDownstreamHotelAdapter shengnuoAdapter,
ShengnuoIdMappingCache shengnuoIdMappingCache,
HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry) {
HotelUpstreamCancelPolicyRegistry upstreamCancelPolicyRegistry,
SmHotelMerchantScopeHelper hotelMerchantScopeHelper) {
this.upstreamClient = upstreamClient;
this.upstreamProperties = upstreamProperties;
this.downstreamClient = downstreamClient;
@@ -152,6 +155,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
this.productMapper = productMapper;
this.dailyMapper = dailyMapper;
this.stringRedisTemplate = stringRedisTemplate;
this.hotelMerchantScopeHelper = hotelMerchantScopeHelper;
}
@Override
@@ -1732,8 +1736,8 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
String productCode = e.getKey();
List<Map<String, Object>> ranges = mergeDailyRanges(rows);
for (List<Map<String, Object>> batch : CollUtil.split(ranges, 50)) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
Map<String, Object> body = new LinkedHashMap<>();
body.put("extHotelId", extHotelId);
body.put("extApartmentId", roomCode);
body.put("extProductId", productCode);
body.put("list", batch);
@@ -2499,7 +2503,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
private SmHotelEntity requireLocalHotel(Long hotelId) {
// 手工维护:有登录态时走部门数据隔离;本地滚加等异步/定时任务无 Authentication,改走无 DataScope 查询
// 手工维护:有登录态时走酒店商户隔离;本地滚加等异步/定时任务无 Authentication,改走无隔离查询
SmHotelEntity hotel;
if (SecurityUtils.getAuthentication() != null) {
hotel = requireHotelInScope(hotelId);
@@ -2518,22 +2522,21 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
}
/**
* 管理端详情/日历/本地维护:必须在当前用户部门数据权限内。
* TOC 订房、上游定时任务仍走 {@link #requireHotel}(不套 DataScope)。
* 管理端详情/日历/本地维护:必须在当前用户酒店商户部门范围内。
* TOC 订房、上游定时任务仍走 {@link #requireHotel}(不套商户隔离)。
*/
private SmHotelEntity requireHotelInScope(Long hotelId) {
if (hotelId == null) {
throw new IllegalArgumentException("酒店ID不能为空");
}
List<SmHotelEntity> list = hotelMapper.selectListByScope(
Wrappers.<SmHotelEntity>lambdaQuery().eq(SmHotelEntity::getId, hotelId), DataScope.of());
if (CollUtil.isEmpty(list)) {
if (hotelMapper.selectById(hotelId) != null) {
throw new IllegalArgumentException("无权访问该酒店(部门数据隔离)");
}
SmHotelEntity hotel = hotelMapper.selectById(hotelId);
if (hotel == null) {
throw new IllegalArgumentException("酒店不存在");
}
return list.get(0);
if (!hotelMerchantScopeHelper.isHotelInScope(hotel)) {
throw new IllegalArgumentException("无权访问该酒店(酒店商户隔离)");
}
return hotel;
}
private SmHotelEntity requireHotel(Long hotelId, boolean requireListed) {
@@ -2852,9 +2855,40 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
* <p>
* 唯一键 uk_product_date=(tenant_id, product_id, stay_date) 不含 del_flag,必须物理删。
* 删除区间须覆盖本次将写入的日期(含回调报文里的历史日),不能写死 LocalDate.now()。
* <p>
* 全量详情多酒店并行时,同租户唯一索引上易出现死锁,失败自动重试几次。
*/
private int persistDailies(SmHotelEntity hotel, List<DailyDraft> drafts, LocalDate fromInclusive,
LocalDate toExclusive) {
final int maxAttempts = 4;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return persistDailiesOnce(hotel, drafts, fromInclusive, toExclusive);
}
catch (Exception e) {
if (!isMysqlDeadlock(e) || attempt >= maxAttempts) {
throw e instanceof RuntimeException ? (RuntimeException) e
: new IllegalStateException(e.getMessage(), e);
}
long sleepMs = 80L * attempt + (long) (Math.random() * 120);
log.warn("|酒店日历落库|死锁重试|hotelId={}|code={}|attempt={}/{}|sleepMs={}|{}",
hotel == null ? null : hotel.getId(),
hotel == null ? null : hotel.getHotelCode(), attempt, maxAttempts, sleepMs,
StrUtil.maxLength(e.getMessage(), 160));
try {
Thread.sleep(sleepMs);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IllegalStateException("日历落库重试被中断", ie);
}
}
}
return 0;
}
private int persistDailiesOnce(SmHotelEntity hotel, List<DailyDraft> drafts, LocalDate fromInclusive,
LocalDate toExclusive) {
LocalDate delFrom = fromInclusive;
LocalDate delTo = toExclusive;
if (delFrom == null || delTo == null || !delFrom.isBefore(delTo)) {
@@ -2930,6 +2964,23 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
return toInsert.size();
}
/** 并发拉详情时,日历表 uk_product_date 易出现 InnoDB 死锁 */
private static boolean isMysqlDeadlock(Throwable e) {
Throwable cur = e;
while (cur != null) {
String msg = cur.getMessage();
if (msg != null && (msg.contains("Deadlock") || msg.contains("try restarting transaction"))) {
return true;
}
String name = cur.getClass().getName();
if (name.contains("MySQLTransactionRollbackException") || name.contains("DeadlockLoser")) {
return true;
}
cur = cur.getCause();
}
return false;
}
private Map<String, SmHotelRoomEntity> loadRoomMap(Long hotelId) {
List<SmHotelRoomEntity> rows = roomMapper.selectList(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId));
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
@@ -81,6 +82,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
List<Long> allowedIds = scopeR.getData();
long current = toLong(body.get("current"), 1L);
long size = Math.min(100L, toLong(body.get("size"), 20L));
// 未配授权范围:C 端直接空列表,不抛运营配置文案
if (CollUtil.isEmpty(allowedIds)) {
return R.ok(emptyHotelPage(current, size));
}
String keyword = StrUtil.trim(str(body.get("keyword")));
String district = StrUtil.trim(str(body.get("district")));
Long districtAdcode = toLongObj(body.get("districtAdcode"));
@@ -123,6 +128,14 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
return R.failed(scopeR.getMsg());
}
List<Long> allowedIds = scopeR.getData();
if (CollUtil.isEmpty(allowedIds)) {
Map<String, Object> empty = new LinkedHashMap<>(8);
empty.put("areas", new ArrayList<>());
empty.put("districts", new ArrayList<>());
empty.put("bizAreas", new ArrayList<>());
empty.put("starLevels", new ArrayList<>());
return R.ok(empty);
}
// 授权范围内轻量扫:仅取区域相关列
List<SmHotelEntity> rows = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, SmHotelEntity::getBizArea)
@@ -183,6 +196,12 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
long current = toLong(body.get("current"), 1L);
long size = Math.min(100L, toLong(body.get("size"), 50L));
LocalDateTime updatedSince = parseDateTime(str(body.get("updatedSince")));
if (CollUtil.isEmpty(allowedIds)) {
Map<String, Object> data = emptyHotelPage(current, size);
data.put("nextUpdatedSince", updatedSince == null ? null : updatedSince.format(DT));
data.put("hasMore", false);
return R.ok(data);
}
boolean includeOffline = Boolean.TRUE.equals(body.get("includeOffline"))
|| "1".equals(str(body.get("includeOffline")))
|| "true".equalsIgnoreCase(str(body.get("includeOffline")));
@@ -265,6 +284,7 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
row.put("mealType", p.getMealType());
row.put("mealCount", p.getMealCount());
row.put("sellPrice", p.getSellPrice());
row.put("stock", p.getStock() == null ? 0 : p.getStock());
productList.add(row);
}
data.put("products", productList);
@@ -307,9 +327,15 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
row.put("productCode", d.getProductCode());
row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString());
row.put("sellPrice", d.getSellPrice());
row.put("costPrice", d.getCostPrice());
row.put("stock", d.getStock());
row.put("sold", d.getSold());
row.put("remain", d.getRemainStock());
// remainStock 非表字段,开放接口按 stock-sold 计算,避免下游永远拿不到剩余
int stock = d.getStock() == null ? 0 : d.getStock();
int sold = d.getSold() == null ? 0 : d.getSold();
int remain = Math.max(0, stock - sold);
row.put("remain", remain);
row.put("remainStock", remain);
row.put("dailyStatus", d.getDailyStatus());
list.add(row);
}
@@ -333,6 +359,10 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
checkBody.put("checkOut", body.get("checkOutDate"));
checkBody.put("roomCnt", body.get("count") != null ? body.get("count") : body.get("quantity"));
checkBody.put("price", body.get("totalAmount") != null ? body.get("totalAmount") : body.get("totalPrice"));
String partner = resolvePartnerCode(app);
if (StrUtil.isNotBlank(partner)) {
checkBody.put("partnerCode", partner);
}
R<Map<String, Object>> r = hotelOrderService.checkBookableForDownstream(checkBody);
if (r == null || !r.isOk()) {
return r == null ? R.failed("可订校验失败") : r;
@@ -376,7 +406,7 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
}
/**
* 解析授权酒店 ID;未配置任何启用映射时拒绝(避免开放应用默认可看全库)。
* 解析授权酒店 ID。未配置映射时返回空列表(列表/同步给空结果,不默认可看全库)。
*/
private R<List<Long>> requireAllowedHotelIds(SmHotelDownstreamAppEntity app) {
String partner = resolvePartnerCode(app);
@@ -384,11 +414,16 @@ public class SmHotelOpenApiServiceImpl implements SmHotelOpenApiService {
return R.failed("开放应用未配置伙伴编码");
}
List<Long> ids = channelDeptService.listHotelIdsForDownstreamPartner(partner);
if (CollUtil.isEmpty(ids)) {
return R.failed("未配置授权酒店范围,请先在「渠道部门映射」为伙伴「" + partner
+ "」配置下游授权(按部门或指定酒店)并启用");
}
return R.ok(ids);
return R.ok(ids == null ? Collections.emptyList() : ids);
}
private static Map<String, Object> emptyHotelPage(long current, long size) {
Map<String, Object> data = new LinkedHashMap<>(8);
data.put("records", new ArrayList<>());
data.put("total", 0);
data.put("current", current);
data.put("size", size);
return data;
}
private R<Void> assertHotelInScope(SmHotelDownstreamAppEntity app, Long hotelId) {
@@ -3,6 +3,7 @@ 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.bsd.pjcloud.supermarket.config.SmHotelOpenProperties;
import com.bsd.pjcloud.supermarket.entity.SmHotelDownstreamAppEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamOpenCallbackClient;
@@ -14,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -44,6 +46,8 @@ public class SmHotelOpenResourceNotifyServiceImpl implements SmHotelOpenResource
private final DownstreamOpenCallbackClient callbackClient;
private final SmHotelOpenProperties openProperties;
@Override
public void notifyByHotelId(Long hotelId, String changeType) {
if (hotelId == null) {
@@ -95,6 +99,58 @@ public class SmHotelOpenResourceNotifyServiceImpl implements SmHotelOpenResource
}
}
@Override
public int notifyDailyFullToOpenApps() {
return notifyDailyFullInternal(null);
}
@Override
public int notifyDailyFullToPartner(String partnerCode) {
String code = StrUtil.trim(partnerCode);
if (StrUtil.isBlank(code)) {
return 0;
}
return notifyDailyFullInternal(code);
}
/**
* @param partnerFilter 空=全部启用回调的开放应用;非空=按 partnerCode/appKey 精确匹配
*/
private int notifyDailyFullInternal(String partnerFilter) {
if (openProperties != null && !openProperties.isDailyResourceNotifyEnabled() && StrUtil.isBlank(partnerFilter)) {
return 0;
}
List<SmHotelDownstreamAppEntity> apps = appMapper.selectList(Wrappers.<SmHotelDownstreamAppEntity>lambdaQuery()
.eq(SmHotelDownstreamAppEntity::getStatus, "1")
.eq(SmHotelDownstreamAppEntity::getCallbackEnabled, "1"));
if (CollUtil.isEmpty(apps)) {
return 0;
}
Map<String, Object> payload = new LinkedHashMap<>(6);
payload.put("hotelCode", "*");
payload.put("changeType", CHANGE_DAILY_FULL);
payload.put("triggerAt", LocalDateTime.now().toString());
payload.put("hint", "请下游全量拉取授权范围内酒店主数据与价态");
int hit = 0;
for (SmHotelDownstreamAppEntity app : apps) {
if (app == null || StrUtil.hasBlank(app.getCallbackBaseUrl(), app.getCallbackResourcePath())) {
continue;
}
if (StrUtil.isNotBlank(partnerFilter)) {
String partner = StrUtil.blankToDefault(StrUtil.trim(app.getPartnerCode()), StrUtil.trim(app.getAppKey()));
String appKey = StrUtil.trim(app.getAppKey());
if (!partnerFilter.equalsIgnoreCase(partner) && !partnerFilter.equalsIgnoreCase(appKey)) {
continue;
}
}
callbackClient.notifyResourceChangeAsync(app.getId(), "*", CHANGE_DAILY_FULL, payload);
hit++;
log.info("|开放资源回调|日更全量信号|appId={}|appKey={}|filter={}|", app.getId(), app.getAppKey(),
partnerFilter);
}
return hit;
}
private boolean inScope(String partner, Long hotelId) {
try {
List<Long> ids = channelDeptService.listHotelIdsForDownstreamPartner(partner);
@@ -1,6 +1,8 @@
package com.bsd.pjcloud.supermarket.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderUpstreamLogEntity;
@@ -12,6 +14,7 @@ import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Slf4j
@Service
@@ -19,7 +22,11 @@ public class SmHotelOrderUpstreamLogServiceImpl
extends ServiceImpl<SmHotelOrderUpstreamLogMapper, SmHotelOrderUpstreamLogEntity>
implements SmHotelOrderUpstreamLogService {
private static final int JSON_MAX = 8000;
/** 下单报文含 dailyPriceList,留足空间便于事后对账 */
private static final int JSON_MAX = 16000;
private static final Set<String> SECRET_KEYS = Set.of("cardPassWord", "cardPassword", "password", "secret",
"appSecret", "accessToken", "token");
@Override
public void saveCall(Long orderId, String channel, String action, HotelUpstreamOrderResult result, String reqJson,
@@ -53,7 +60,7 @@ public class SmHotelOrderUpstreamLogServiceImpl
row.setSuccess(success ? "1" : "0");
row.setBizCode(StrUtil.sub(bizCode, 0, 64));
row.setMessage(StrUtil.sub(message, 0, 500));
row.setReqJson(clip(reqJson));
row.setReqJson(clip(maskSecrets(reqJson)));
row.setRespJson(clip(respJson));
row.setCostMs(costMs);
baseMapper.insert(row);
@@ -73,6 +80,27 @@ public class SmHotelOrderUpstreamLogServiceImpl
.orderByAsc(SmHotelOrderUpstreamLogEntity::getCreateTime));
}
/** 落库前脱敏,避免 cardPassWord 等进流水 */
static String maskSecrets(String raw) {
if (StrUtil.isBlank(raw) || !StrUtil.startWith(raw.trim(), "{")) {
return raw;
}
try {
JSONObject obj = JSONUtil.parseObj(raw);
boolean changed = false;
for (String key : SECRET_KEYS) {
if (obj.containsKey(key) && obj.get(key) != null && StrUtil.isNotBlank(String.valueOf(obj.get(key)))) {
obj.set(key, "***");
changed = true;
}
}
return changed ? obj.toString() : raw;
}
catch (Exception e) {
return raw;
}
}
private static String clip(String s) {
if (s == null) {
return null;
@@ -20,10 +20,10 @@ import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamAutoPushService;
import com.bsd.pjcloud.supermarket.service.SmHotelOpenResourceNotifyService;
import com.bsd.pjcloud.supermarket.service.SmHotelService;
import com.bsd.pjcloud.supermarket.util.SmHotelMerchantScopeHelper;
import com.bsd.pjcloud.supermarket.vo.SmHotelImportResultVO;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
@@ -60,10 +60,14 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
private final SmHotelOpenResourceNotifyService openResourceNotifyService;
private final SmHotelMerchantScopeHelper hotelMerchantScopeHelper;
public SmHotelServiceImpl(@Lazy SmHotelDownstreamAutoPushService downstreamAutoPushService,
@Lazy SmHotelOpenResourceNotifyService openResourceNotifyService) {
@Lazy SmHotelOpenResourceNotifyService openResourceNotifyService,
SmHotelMerchantScopeHelper hotelMerchantScopeHelper) {
this.downstreamAutoPushService = downstreamAutoPushService;
this.openResourceNotifyService = openResourceNotifyService;
this.hotelMerchantScopeHelper = hotelMerchantScopeHelper;
}
/** 管理端「可售」窗口:今天起共 N 天内有开房且可售库存 */
@@ -73,6 +77,7 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
public Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page,
LambdaQueryWrapper<SmHotelEntity> wrapper) {
// 不再聚合房型/产品/日历数;列表只查展示列,丢掉 intro/轮播等大字段减轻 IO
// coverImage 为短路径字符串,卡片封面需要;carouselImages/intro 仍不查
wrapper.select(SmHotelEntity::getId, SmHotelEntity::getTenantId, SmHotelEntity::getDeptId,
SmHotelEntity::getSourceId, SmHotelEntity::getGroupCode, SmHotelEntity::getHotelCode,
SmHotelEntity::getSGroupId, SmHotelEntity::getSHotelId, SmHotelEntity::getHotelName,
@@ -80,9 +85,12 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
SmHotelEntity::getLocalAutoRoll, SmHotelEntity::getLocalRollHorizonDays,
SmHotelEntity::getDistrict, SmHotelEntity::getDistrictAdcode, SmHotelEntity::getBizArea,
SmHotelEntity::getStarLevel, SmHotelEntity::getScore, SmHotelEntity::getAddress,
SmHotelEntity::getPhone, SmHotelEntity::getCreateTime, SmHotelEntity::getUpdateTime,
SmHotelEntity::getPhone, SmHotelEntity::getCoverImage,
SmHotelEntity::getCreateTime, SmHotelEntity::getUpdateTime,
SmHotelEntity::getDelFlag);
return baseMapper.selectPageByScope(page, wrapper, DataScope.of());
// App/管家:按酒店商户绑定部门隔离;无酒店商户则无数据
hotelMerchantScopeHelper.applyDeptScope(wrapper);
return baseMapper.selectPage(page, wrapper);
}
@Override
@@ -95,16 +103,14 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
if (id == null) {
throw new IllegalArgumentException("酒店ID不能为空");
}
List<SmHotelEntity> list = baseMapper.selectListByScope(
Wrappers.<SmHotelEntity>lambdaQuery().eq(SmHotelEntity::getId, id), DataScope.of());
if (CollUtil.isEmpty(list)) {
// 区分不存在与无权限:存在则明确拒绝跨部门访问
if (baseMapper.selectById(id) != null) {
throw new IllegalArgumentException("无权访问该酒店(部门数据隔离)");
}
SmHotelEntity hotel = baseMapper.selectById(id);
if (hotel == null) {
throw new IllegalArgumentException("酒店不存在");
}
return list.get(0);
if (!hotelMerchantScopeHelper.isHotelInScope(hotel)) {
throw new IllegalArgumentException("无权访问该酒店(酒店商户隔离)");
}
return hotel;
}
@Override
@@ -167,7 +173,14 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
}
normalizeLocalRoll(entity);
if (entity.getDeptId() == null) {
entity.setDeptId(SecurityUtils.getUser().getDeptId());
Long defaultDeptId = hotelMerchantScopeHelper.resolveDefaultHotelDeptId();
if (defaultDeptId == null) {
throw new IllegalArgumentException("当前账号未绑定酒店商户,无法新增酒店");
}
entity.setDeptId(defaultDeptId);
}
else if (!hotelMerchantScopeHelper.isHotelInScope(entity)) {
throw new IllegalArgumentException("无权在该部门下新增酒店(酒店商户隔离)");
}
baseMapper.insert(entity);
// 开放下游:资料新增回调(授权范围内的开放应用)
@@ -127,6 +127,9 @@ public class SmHotelShengnuoOrderServiceImpl implements SmHotelShengnuoOrderServ
checkBody.put("checkOut", m.checkOut.toString());
checkBody.put("roomCnt", m.quantity);
checkBody.put("price", m.orderAmount);
if (StrUtil.isNotBlank(partnerCode)) {
checkBody.put("partnerCode", partnerCode);
}
R<Map<String, Object>> check = hotelOrderService.checkBookableForDownstream(checkBody);
Map<String, Object> checkData = check != null && check.getCode() == 0 ? check.getData() : null;
boolean bookable = checkData != null && Boolean.TRUE.equals(checkData.get("bookable"));
@@ -21,6 +21,7 @@ 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.SmHotelDownstreamAppService;
import com.bsd.pjcloud.supermarket.service.SmHotelDownstreamPushLogService;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
@@ -41,6 +42,7 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -65,6 +67,15 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private static final String TYPE_DETAIL = "DETAIL_PULL";
/** 按下游渠道范围(单伙伴或合集)拉上游详情 */
private static final String TYPE_DETAIL_SCOPE = "DETAIL_PULL_SCOPE";
/** 近窗详情:范围合集/单伙伴只拉未来 N 天(不占全量配额) */
private static final String TYPE_DETAIL_NEAR = "DETAIL_PULL_NEAR";
/** 范围合集详情任务在同步日志 dataSource 上的标记 */
private static final String SCOPE_UNION_DS = "scope_union";
/** 按下游范围映射全量推送 */
private static final String TYPE_DOWNSTREAM_PUSH = "DOWNSTREAM_PUSH";
@@ -104,6 +115,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private final SmHotelChannelDeptService channelDeptService;
private final SmHotelDownstreamAppService downstreamAppService;
private final SmHotelMapper hotelMapper;
private final SmHotelSyncLogMapper syncLogMapper;
@@ -240,11 +253,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
@Override
public R<SmHotelSyncLogEntity> startDetailPullAsync(Integer limit, String triggerType, String dataSource) {
abortStaleRunningSyncLogs();
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_DETAIL)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有全量详情拉取任务进行中,请打开「同步日志」查看进度(若长时间卡住请点「终止」)");
if (hasRunningHeavyDetailPull()) {
return R.failed("已有全量详情/范围详情拉取任务进行中,请打开「同步日志」查看进度(若长时间卡住请点「终止」)");
}
R<Long> deptR = channelDeptService.requireEnabledDeptId(dataSource);
if (deptR == null || !deptR.isOk() || deptR.getData() == null) {
@@ -281,6 +291,163 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.ok(logEntity, "已提交全量详情拉取");
}
@Override
public R<SmHotelSyncLogEntity> startScopeDetailPullAsync(String partnerCode, String triggerType) {
String partner = SmHotelDataSource.normalize(partnerCode).toLowerCase();
if (StrUtil.isBlank(partner)) {
return R.failed("请选择下游渠道/伙伴");
}
List<Long> raw = channelDeptService.listHotelIdsForDownstreamPartner(partner);
return startScopeDetailPullInternal(raw, partner, "渠道「" + partner + "」范围", triggerType);
}
@Override
public R<SmHotelSyncLogEntity> startScopeUnionDetailPullAsync(String triggerType) {
List<Long> raw = channelDeptService.listHotelIdsUnionAllDownstreamScopes();
return startScopeDetailPullInternal(raw, SCOPE_UNION_DS, "全部渠道范围合集", triggerType);
}
@Override
public R<SmHotelSyncLogEntity> startNearScopeDetailPullAsync(String partnerCode, Integer days,
String triggerType) {
String partner = SmHotelDataSource.normalize(partnerCode).toLowerCase();
List<Long> raw;
String dsLabel;
String scopeLabel;
if (StrUtil.isBlank(partner) || SCOPE_UNION_DS.equals(partner)) {
raw = channelDeptService.listHotelIdsUnionAllDownstreamScopes();
dsLabel = SCOPE_UNION_DS;
scopeLabel = "全部渠道范围合集";
}
else {
raw = channelDeptService.listHotelIdsForDownstreamPartner(partner);
dsLabel = partner;
scopeLabel = "渠道「" + partner + "」范围";
}
int nearDays = resolveNearDetailDays(days);
return startNearScopeDetailPullInternal(raw, dsLabel, scopeLabel, nearDays, triggerType);
}
@Override
public Map<String, Object> previewScopeDetailPull(String partnerCode) {
String partner = SmHotelDataSource.normalize(partnerCode).toLowerCase();
List<Long> raw;
String label;
if (StrUtil.isBlank(partner) || SCOPE_UNION_DS.equals(partner)) {
raw = channelDeptService.listHotelIdsUnionAllDownstreamScopes();
label = SCOPE_UNION_DS;
}
else {
raw = channelDeptService.listHotelIdsForDownstreamPartner(partner);
label = partner;
}
int rawCount = raw == null ? 0 : raw.size();
List<Long> pullable = filterUpstreamPullableHotelIds(raw);
int pullCount = pullable.size();
Map<String, Object> data = new LinkedHashMap<>(8);
data.put("partnerCode", label);
data.put("rawHotelCount", rawCount);
data.put("hotelCount", pullCount);
data.put("localSkipped", Math.max(0, rawCount - pullCount));
data.put("nearDetailPullDays", resolveNearDetailDays(null));
return data;
}
/**
* 范围详情:落任务日志后按酒店 ID 列表异步拉取(已过滤本地)。
*/
private R<SmHotelSyncLogEntity> startScopeDetailPullInternal(List<Long> raw, String dataSourceLabel,
String scopeLabel, String triggerType) {
abortStaleRunningSyncLogs();
if (hasRunningHeavyDetailPull()) {
return R.failed("已有全量详情/范围详情拉取任务进行中,请打开「同步日志」查看进度(若长时间卡住请点「终止」)");
}
String quotaErr = checkFullPriceSyncQuota();
if (quotaErr != null) {
return R.failed(quotaErr);
}
int rawCount = raw == null ? 0 : raw.size();
List<Long> hotelIds = filterUpstreamPullableHotelIds(raw);
if (CollUtil.isEmpty(hotelIds)) {
return R.failed(rawCount > 0
? scopeLabel + "内 " + rawCount + " 家均为本地或不可拉上游,无需拉取"
: scopeLabel + "没有上架酒店");
}
int skippedLocal = Math.max(0, rawCount - hotelIds.size());
Integer priceDays = props().getDetailPullPriceDays() > 0 ? props().getDetailPullPriceDays() : null;
SmHotelSyncLogEntity logEntity = newLog(TYPE_DETAIL_SCOPE, triggerType);
logEntity.setDataSource(dataSourceLabel);
logEntity.setStage("QUEUED");
logEntity.setStageMsg("已提交" + scopeLabel + "详情拉取:上游 " + hotelIds.size() + " 家"
+ (skippedLocal > 0 ? "(跳过本地/不可拉 " + skippedLocal + ")" : "") + "…");
logEntity.setProgressPct(1);
logEntity.setLocalCount((long) hotelIds.size());
logEntity.setSkipCount(skippedLocal);
syncLogMapper.insert(logEntity);
markFullPriceSyncStarted();
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
final List<Long> ids = List.copyOf(hotelIds);
final int total = hotelIds.size();
final int baseSkip = skippedLocal;
final Integer daysOverride = priceDays;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDetailPullByHotelIds(logId, ids, 0, 0, 0,
baseSkip, new ArrayList<>(), total, daysOverride)));
return R.ok(logEntity, "已提交范围详情拉取,共 " + hotelIds.size() + " 家上游酒店");
}
/**
* 近窗详情:不占全量 6h/每日次数;与全量/范围重任务可错开,自身互斥。
*/
private R<SmHotelSyncLogEntity> startNearScopeDetailPullInternal(List<Long> raw, String dataSourceLabel,
String scopeLabel, int nearDays, String triggerType) {
abortStaleRunningSyncLogs();
if (hasRunningNearDetailPull()) {
return R.failed("已有近窗详情拉取任务进行中,请打开「同步日志」查看进度(若长时间卡住请点「终止」)");
}
if (hasRunningHeavyDetailPull()) {
return R.failed("全量/范围详情拉取进行中,近窗任务暂不插队,请稍后再试");
}
int rawCount = raw == null ? 0 : raw.size();
List<Long> hotelIds = filterUpstreamPullableHotelIds(raw);
if (CollUtil.isEmpty(hotelIds)) {
return R.failed(rawCount > 0
? scopeLabel + "内 " + rawCount + " 家均为本地或不可拉上游,无需拉取"
: scopeLabel + "没有上架酒店");
}
int skippedLocal = Math.max(0, rawCount - hotelIds.size());
SmHotelSyncLogEntity logEntity = newLog(TYPE_DETAIL_NEAR, triggerType);
logEntity.setDataSource(dataSourceLabel);
logEntity.setStage("QUEUED");
logEntity.setStageMsg("已提交" + scopeLabel + "近窗详情:未来 " + nearDays + " 天,上游 " + hotelIds.size()
+ " 家" + (skippedLocal > 0 ? "(跳过本地/不可拉 " + skippedLocal + ")" : "") + "…");
logEntity.setProgressPct(1);
logEntity.setLocalCount((long) hotelIds.size());
logEntity.setSkipCount(skippedLocal);
// newCount 临时存近窗天数,便于续跑/日志展示
logEntity.setNewCount((long) nearDays);
syncLogMapper.insert(logEntity);
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
final Long logId = logEntity.getId();
final List<Long> ids = List.copyOf(hotelIds);
final int total = hotelIds.size();
final int baseSkip = skippedLocal;
final int days = nearDays;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runDetailPullByHotelIds(logId, ids, 0, 0, 0,
baseSkip, new ArrayList<>(), total, days)));
return R.ok(logEntity, "已提交近窗详情拉取(" + nearDays + " 天),共 " + hotelIds.size() + " 家上游酒店");
}
@Override
public R<SmHotelSyncLogEntity> startDownstreamScopePushAsync(String partnerCode, String triggerType) {
abortStaleRunningSyncLogs();
@@ -289,7 +456,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.failed("请选择下游伙伴");
}
if (!channelConfigService.listEnabledDownstreamPartners().contains(partner)) {
return R.failed("下游伙伴「" + partner + "」未启用或不存在,请先到「渠道对接配置」维护");
// 开放应用是对方主动拉数,不走协议推送;误点「全量推送」时给出明确说明
if (downstreamAppService.isEnabledOpenAppPartner(partner)) {
return R.failed("「" + partner + "」是开放应用(拉模式),无需也不支持「全量推送」。"
+ "请在「渠道范围映射」配好授权即可,对方用 appKey 调开放 API 自行拉酒店/下单");
}
return R.failed("下游伙伴「" + partner + "」未启用或不存在,请先到「渠道对接配置」维护(圣诺/慧订/环启等协议下游)");
}
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_DOWNSTREAM_PUSH)
@@ -354,17 +526,30 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.failed("任务已暂停,请先「继续」或「终止」后再重试失败项");
}
String syncType = src.getSyncType();
if (!TYPE_DETAIL.equals(syncType) && !TYPE_ROLL.equals(syncType)
if (!TYPE_DETAIL.equals(syncType) && !TYPE_DETAIL_SCOPE.equals(syncType)
&& !TYPE_DETAIL_NEAR.equals(syncType) && !TYPE_ROLL.equals(syncType)
&& !TYPE_DOWNSTREAM_PUSH.equals(syncType)) {
return R.failed("仅支持「全量详情」「滚价」「下游全量推」的失败重试");
return R.failed("仅支持「全量详情」「范围详情」「近窗详情」「滚价」「下游全量推」的失败重试");
}
List<String> codes = parseFailHotelCodes(src.getFailDetail());
abortStaleRunningSyncLogs();
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有同类型任务进行中,请稍后再试(若卡住请先暂停/终止)");
if (TYPE_DETAIL.equals(syncType) || TYPE_DETAIL_SCOPE.equals(syncType)) {
if (hasRunningHeavyDetailPull()) {
return R.failed("已有全量详情/范围详情任务进行中,请稍后再试(若卡住请先暂停/终止)");
}
}
else if (TYPE_DETAIL_NEAR.equals(syncType)) {
if (hasRunningNearDetailPull()) {
return R.failed("已有近窗详情任务进行中,请稍后再试(若卡住请先暂停/终止)");
}
}
else {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, syncType)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有同类型任务进行中,请稍后再试(若卡住请先暂停/终止)");
}
}
// 下游全量推:优先从推送流水取失败酒店(可覆盖数万家);failDetail 作补充
@@ -498,8 +683,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.failed("仅能暂停「进行中」的任务,当前状态=" + e.getStatus());
}
String type = e.getSyncType();
if (!TYPE_DOWNSTREAM_PUSH.equals(type) && !TYPE_DETAIL.equals(type)) {
return R.failed("当前仅支持暂停「下游全量推」或「全量详情」;其它类型请用终止");
if (!TYPE_DOWNSTREAM_PUSH.equals(type) && !TYPE_DETAIL.equals(type) && !TYPE_DETAIL_SCOPE.equals(type)
&& !TYPE_DETAIL_NEAR.equals(type)) {
return R.failed("当前仅支持暂停「下游全量推」或「全量/范围/近窗详情」;其它类型请用终止");
}
e.setStatus(STATUS_PAUSED);
e.setStage("PAUSE_REQ");
@@ -524,15 +710,28 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.failed("仅能继续「已暂停」的任务,当前状态=" + e.getStatus());
}
String type = e.getSyncType();
if (!TYPE_DOWNSTREAM_PUSH.equals(type) && !TYPE_DETAIL.equals(type)) {
if (!TYPE_DOWNSTREAM_PUSH.equals(type) && !TYPE_DETAIL.equals(type) && !TYPE_DETAIL_SCOPE.equals(type)
&& !TYPE_DETAIL_NEAR.equals(type)) {
return R.failed("该类型不支持继续,请重新发起任务");
}
abortStaleRunningSyncLogs();
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, type)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有同类型任务进行中,请先等其结束或暂停/终止后再继续");
if (TYPE_DETAIL.equals(type) || TYPE_DETAIL_SCOPE.equals(type)) {
if (hasRunningHeavyDetailPull()) {
return R.failed("已有全量详情/范围详情任务进行中,请先等其结束或暂停/终止后再继续");
}
}
else if (TYPE_DETAIL_NEAR.equals(type)) {
if (hasRunningNearDetailPull()) {
return R.failed("已有近窗详情任务进行中,请先等其结束或暂停/终止后再继续");
}
}
else {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, type)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
if (running != null && running > 0) {
return R.failed("已有同类型任务进行中,请先等其结束或暂停/终止后再继续");
}
}
e.setStatus(STATUS_RUNNING);
@@ -574,6 +773,40 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return R.ok(e, "已继续下游全量推,从第 " + (baseIdx + 1) + " 家接着跑");
}
if (TYPE_DETAIL_SCOPE.equals(type) || TYPE_DETAIL_NEAR.equals(type)) {
String scopeDs = SmHotelDataSource.normalize(e.getDataSource()).toLowerCase();
List<Long> hotelIds = resolveScopeDetailHotelIds(scopeDs);
final int baseOk = nullToZero(e.getSuccessCount());
final int baseFail = nullToZero(e.getFailCount());
final int baseSkip = nullToZero(e.getSkipCount());
int resumeIdx = Math.max(readResumeIdx(logId), baseOk + baseFail);
if (resumeIdx > 0 && CollUtil.isNotEmpty(hotelIds) && resumeIdx < hotelIds.size()) {
hotelIds = hotelIds.subList(resumeIdx, hotelIds.size());
}
else if (resumeIdx > 0 && CollUtil.isNotEmpty(hotelIds) && resumeIdx >= hotelIds.size()) {
return R.failed("检查点已覆盖全部酒店,无需继续;可将本日志视为完成或重新发起");
}
final List<Long> ids = List.copyOf(hotelIds == null ? List.of() : hotelIds);
final int baseIdx = resumeIdx;
final List<String> baseFails = parseFailLines(e.getFailDetail());
final long totalHint = e.getLocalCount() == null ? (baseIdx + ids.size()) : e.getLocalCount();
Integer daysOverride;
if (TYPE_DETAIL_NEAR.equals(type)) {
daysOverride = e.getNewCount() != null && e.getNewCount() > 0
? e.getNewCount().intValue()
: resolveNearDetailDays(null);
}
else {
daysOverride = props().getDetailPullPriceDays() > 0 ? props().getDetailPullPriceDays() : null;
}
final Integer priceDays = daysOverride;
EXECUTOR.execute(() -> TenantBroker.runAs(tid,
x -> runDetailPullByHotelIds(logId, ids, baseIdx, baseOk, baseFail, baseSkip, baseFails,
(int) totalHint, priceDays)));
return R.ok(e, "已继续" + (TYPE_DETAIL_NEAR.equals(type) ? "近窗" : "范围") + "详情拉取,从第 "
+ (baseIdx + 1) + " 家接着跑");
}
// DETAIL_PULL
String channel = SmHotelDataSource.normalize(e.getDataSource()).toLowerCase();
R<Long> deptR = channelDeptService.requireEnabledDeptId(channel);
@@ -737,7 +970,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
for (int i = 0; i < hotelIds.size(); i += batch) {
List<Long> slice = hotelIds.subList(i, Math.min(i + batch, hotelIds.size()));
List<SmHotelEntity> part = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getHotelCode, SmHotelEntity::getHotelName)
.select(SmHotelEntity::getId, SmHotelEntity::getHotelCode, SmHotelEntity::getHotelName,
SmHotelEntity::getSheetName)
.in(SmHotelEntity::getId, slice));
if (CollUtil.isNotEmpty(part)) {
for (SmHotelEntity h : part) {
@@ -1804,6 +2038,265 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
}
/**
* 按酒店 ID 列表拉上游详情(渠道范围/合集/近窗);跳过本地在入队前已过滤。
* @param hotelIds 本段待拉列表(续跑时已去掉前面已处理)
* @param baseProcessed 续跑前已处理条数(进度分母用 totalAll)
* @param totalAll 上游目标家数(不含预先跳过的本地)
* @param priceDaysOverride 房价天数覆盖;null 则用配置 detail-pull-price-days / price-days
*/
private void runDetailPullByHotelIds(Long logId, List<Long> hotelIds, int baseProcessed, int baseOk,
int baseFail, int baseSkip, List<String> baseFailLines, int totalAll, Integer priceDaysOverride) {
long t0 = System.currentTimeMillis();
int total = totalAll > 0 ? totalAll : (baseProcessed + (hotelIds == null ? 0 : hotelIds.size()));
AtomicInteger ok = new AtomicInteger(Math.max(0, baseOk));
AtomicInteger fail = new AtomicInteger(Math.max(0, baseFail));
AtomicInteger skip = new AtomicInteger(Math.max(0, baseSkip));
AtomicInteger processed = new AtomicInteger(Math.max(0, baseProcessed));
List<String> failLines = baseFailLines == null ? new ArrayList<>() : new ArrayList<>(baseFailLines);
final Object failLock = new Object();
ExecutorService pullPool = null;
try {
int concurrency = Math.max(1, props().getDetailPullConcurrency());
int intervalMs = Math.max(0, props().getDetailPullIntervalMs());
Integer daysOverride = priceDaysOverride;
if (daysOverride == null || daysOverride <= 0) {
daysOverride = props().getDetailPullPriceDays() > 0 ? props().getDetailPullPriceDays() : null;
}
final Integer priceDaysParam = daysOverride;
int effectivePriceDays = daysOverride != null ? daysOverride : props().getPriceDays();
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
}
final Long tid = tenantId;
progressSnapshot(logId, Math.min(95, 5 + (int) (processed.get() * 90L / Math.max(1, total))), "PULL",
(baseProcessed > 0 ? "继续" : "开始") + "详情拉取,目标 " + total + " 家上游,已处理 "
+ processed.get() + ",并发=" + concurrency + " price-days=" + effectivePriceDays
+ "…",
ok.get(), fail.get(), skip.get(), total, failLines);
if (CollUtil.isEmpty(hotelIds)) {
// fall through complete
}
else {
pullPool = Executors.newFixedThreadPool(concurrency, r -> {
Thread th = new Thread(r, "sm-hotel-scope-detail-pull");
th.setDaemon(true);
return th;
});
int batchSize = Math.max(concurrency * 2, 20);
for (int i = 0; i < hotelIds.size(); i += batchSize) {
if (handleStopIfNeeded(logId, t0, total, ok, fail, skip, processed, failLines)) {
return;
}
List<Long> slice = hotelIds.subList(i, Math.min(i + batchSize, hotelIds.size()));
Map<Long, SmHotelEntity> hotelMap = loadHotelMapByIds(slice);
List<Callable<Void>> tasks = new ArrayList<>(slice.size());
for (Long hotelId : slice) {
SmHotelEntity h = hotelMap.get(hotelId);
tasks.add(() -> {
TenantBroker.runAs(tid, id -> {
try {
if (h == null || h.getId() == null) {
skip.incrementAndGet();
return;
}
if (!SmHotelDataSource.isWritableByChannel(h.getSheetName(),
SmHotelDataSource.SHANGYOU)) {
skip.incrementAndGet();
return;
}
if (StrUtil.isBlank(h.getHotelCode())) {
skip.incrementAndGet();
return;
}
R<Map<String, Object>> r = midService.pullFromUpstream(h.getId(),
priceDaysParam);
if (r != null && r.getCode() == 0) {
ok.incrementAndGet();
}
else {
fail.incrementAndGet();
synchronized (failLock) {
appendFail(failLines, h, r == null ? "返回为空"
: StrUtil.blankToDefault(r.getMsg(), "业务失败"));
}
}
}
catch (Exception ex) {
fail.incrementAndGet();
if (h != null) {
synchronized (failLock) {
appendFail(failLines, h, ex.getMessage());
}
}
log.warn("|范围/近窗详情|单店失败|hotelId={}|{}", hotelId, ex.getMessage());
}
finally {
int doneCnt = processed.incrementAndGet();
if (intervalMs > 0) {
try {
Thread.sleep(intervalMs);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
if (doneCnt % 20 == 0 || doneCnt >= total) {
int pct = total <= 0 ? 50
: Math.min(95, 5 + (int) (doneCnt * 90L / total));
progressSnapshot(logId, pct, "PULL",
"拉取中 " + doneCnt + "/" + total + " 成功 " + ok.get()
+ " 失败 " + fail.get() + " 并发=" + concurrency,
ok.get(), fail.get(), skip.get(), total, failLines);
}
}
});
return null;
});
}
List<Future<Void>> futures = pullPool.invokeAll(tasks);
for (Future<Void> f : futures) {
try {
f.get();
}
catch (Exception ex) {
log.warn("|范围详情|批次任务异常|{}", ex.getMessage());
}
}
saveResumeIdx(logId, processed.get());
progressSnapshot(logId,
total <= 0 ? 50 : Math.min(95, 5 + (int) (processed.get() * 90L / total)), "PULL",
"拉取中 " + processed.get() + "/" + total + " 成功 " + ok.get() + " 失败 "
+ fail.get() + " 跳过 " + skip.get(),
ok.get(), fail.get(), skip.get(), total, failLines);
}
}
if (handleStopIfNeeded(logId, t0, total, ok, fail, skip, processed, failLines)) {
return;
}
SmHotelSyncLogEntity done = syncLogMapper.selectById(logId);
if (done == null) {
return;
}
done.setStatus(STATUS_SUCCESS);
done.setStage("DONE");
done.setStageMsg("范围详情拉取完成(并发=" + concurrency + ",priceDays=" + effectivePriceDays + ")");
done.setProgressPct(100);
done.setSuccessCount(ok.get());
done.setFailCount(fail.get());
done.setSkipCount(skip.get());
done.setLocalCount((long) total);
applyFailDetail(done, failLines, fail.get());
done.setCostMs(System.currentTimeMillis() - t0);
done.setFinishTime(LocalDateTime.now());
done.setUpdateTime(LocalDateTime.now());
syncLogMapper.updateById(done);
clearResumeCkpt(logId);
log.info("|范围详情|完成|logId={}|ok={}|fail={}|skip={}|concurrency={}|priceDays={}|costMs={}|", logId,
ok.get(), fail.get(), skip.get(), concurrency, effectivePriceDays, done.getCostMs());
}
catch (Throwable e) {
log.error("|范围详情|失败|logId={}|", logId, e);
flushThenFail(logId, e.getMessage(), System.currentTimeMillis() - t0, ok, fail, skip, processed, 0,
failLines);
}
finally {
if (pullPool != null) {
pullPool.shutdown();
try {
if (!pullPool.awaitTermination(30, TimeUnit.SECONDS)) {
pullPool.shutdownNow();
}
}
catch (InterruptedException ie) {
pullPool.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
}
/** 全量详情与范围详情互斥(不含近窗)。 */
private boolean hasRunningHeavyDetailPull() {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.in(SmHotelSyncLogEntity::getSyncType, TYPE_DETAIL, TYPE_DETAIL_SCOPE)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
return running != null && running > 0;
}
/** 近窗详情自身互斥。 */
private boolean hasRunningNearDetailPull() {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_DETAIL_NEAR)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
return running != null && running > 0;
}
private int resolveNearDetailDays(Integer days) {
if (days != null && days > 0) {
return Math.min(90, days);
}
int configured = props().getNearDetailPullDays();
if (configured <= 0) {
configured = 15;
}
return Math.min(90, configured);
}
/** 解析范围详情任务对应的上游可拉酒店 ID(续跑用)。 */
private List<Long> resolveScopeDetailHotelIds(String dataSourceLabel) {
String ds = SmHotelDataSource.normalize(dataSourceLabel).toLowerCase();
List<Long> raw;
if (StrUtil.isBlank(ds) || SCOPE_UNION_DS.equals(ds)) {
raw = channelDeptService.listHotelIdsUnionAllDownstreamScopes();
}
else {
raw = channelDeptService.listHotelIdsForDownstreamPartner(ds);
}
return filterUpstreamPullableHotelIds(raw);
}
/**
* 只保留商游可写(非本地、来源匹配)的酒店,保持原顺序。
*/
private List<Long> filterUpstreamPullableHotelIds(List<Long> hotelIds) {
if (CollUtil.isEmpty(hotelIds)) {
return List.of();
}
Map<Long, String> sheetById = new HashMap<>();
int batch = 500;
for (int i = 0; i < hotelIds.size(); i += batch) {
List<Long> slice = hotelIds.subList(i, Math.min(i + batch, hotelIds.size()));
List<SmHotelEntity> rows = hotelMapper.selectList(Wrappers.<SmHotelEntity>lambdaQuery()
.select(SmHotelEntity::getId, SmHotelEntity::getSheetName)
.in(SmHotelEntity::getId, slice));
if (CollUtil.isEmpty(rows)) {
continue;
}
for (SmHotelEntity h : rows) {
if (h != null && h.getId() != null) {
sheetById.put(h.getId(), h.getSheetName());
}
}
}
List<Long> kept = new ArrayList<>(hotelIds.size());
Set<Long> seen = new LinkedHashSet<>();
for (Long id : hotelIds) {
if (id == null || !seen.add(id)) {
continue;
}
if (SmHotelDataSource.isWritableByChannel(sheetById.get(id), SmHotelDataSource.SHANGYOU)) {
kept.add(id);
}
}
return kept;
}
/**
* 仅重试某次失败明细里的酒店码(详情拉取或滚价)。
*/
@@ -2015,8 +2508,20 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return;
}
String code = StrUtil.blankToDefault(h.getHotelCode(), String.valueOf(h.getId()));
String name = StrUtil.blankToDefault(h.getHotelName(), "-");
failLines.add(code + "|" + name + "|" + StrUtil.maxLength(StrUtil.blankToDefault(reason, "未知错误"), 300));
String name = StrUtil.blankToDefault(h.getHotelName(), "-")
.replace('|', '/')
.replace('\r', ' ')
.replace('\n', ' ');
String raw = StrUtil.blankToDefault(reason, "未知错误");
// MyBatis 堆栈含换行会把导出 Excel 拆成多行;死锁写成短文案便于重试
String safe;
if (raw.contains("Deadlock") || raw.contains("try restarting transaction")) {
safe = "日历落库死锁(可点失败重试)";
}
else {
safe = raw.replace('\r', ' ').replace('\n', ' ').replace('|', '/');
}
failLines.add(code + "|" + name + "|" + StrUtil.maxLength(safe, 300));
}
private static void applyFailDetailOnly(SmHotelSyncLogEntity done, List<String> failLines, int fail) {
@@ -2332,10 +2837,22 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
m.put("priceRollDailyCron", props().getPriceRollDailyCron());
m.put("localDailyRollEnabled", props().isLocalDailyRollEnabled());
m.put("localDailyRollCron", props().getLocalDailyRollCron());
m.put("detailPullDailyEnabled", props().isDetailPullDailyEnabled());
m.put("detailPullDailyCron", props().getDetailPullDailyCron());
m.put("scopeDetailPullDailyEnabled", props().isScopeDetailPullDailyEnabled());
m.put("scopeDetailPullDailyCron", props().getScopeDetailPullDailyCron());
m.put("nearDetailPullEnabled", props().isNearDetailPullEnabled());
m.put("nearDetailPullDays", props().getNearDetailPullDays());
m.put("nearDetailPullCron", props().getNearDetailPullCron());
m.put("fullPriceSyncMaxPerDay", props().getFullPriceSyncMaxPerDay());
m.put("fullPriceSyncMinIntervalHours", props().getFullPriceSyncMinIntervalHours());
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)");
m.put("hint", "近窗详情 near-detail-pull-* 刷渠道范围未来 "
+ props().getNearDetailPullDays() + " 天(不占全量配额);"
+ "全量/范围详情共用上游 " + props().getFullPriceSyncMinIntervalHours() + "h 与每日"
+ props().getFullPriceSyncMaxPerDay() + " 次;"
+ "XXL:smHotelNearDetailPullJobHandler");
return m;
}
@@ -2399,4 +2916,67 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return Map.of("ok", true, "async", true, "logId", r.getData().getId());
});
}
@Override
public Map<String, Object> jobDetailPull() {
return jobDetailPull("XXL");
}
@Override
public Map<String, Object> jobDetailPull(String triggerType) {
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!props().isDetailPullDailyEnabled()) {
return Map.of("skipped", true, "reason", "detail-pull-daily-enabled=false");
}
String ds = StrUtil.blankToDefault(props().getDefaultImportDataSource(), SmHotelDataSource.SHANGYOU);
R<SmHotelSyncLogEntity> r = startDetailPullAsync(null, StrUtil.blankToDefault(triggerType, "XXL"), ds);
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}
return Map.of("ok", true, "async", true, "logId", r.getData().getId(), "dataSource", ds);
});
}
@Override
public Map<String, Object> jobScopeDetailPull() {
return jobScopeDetailPull("XXL");
}
@Override
public Map<String, Object> jobScopeDetailPull(String triggerType) {
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!props().isScopeDetailPullDailyEnabled()) {
return Map.of("skipped", true, "reason", "scope-detail-pull-daily-enabled=false");
}
R<SmHotelSyncLogEntity> r = startScopeUnionDetailPullAsync(StrUtil.blankToDefault(triggerType, "XXL"));
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}
return Map.of("ok", true, "async", true, "logId", r.getData().getId());
});
}
@Override
public Map<String, Object> jobNearDetailPull() {
return jobNearDetailPull("XXL");
}
@Override
public Map<String, Object> jobNearDetailPull(String triggerType) {
Long tenantId = props().getCallbackTenantId() == null ? 1L : props().getCallbackTenantId();
return TenantBroker.applyAs(tenantId, tid -> {
if (!props().isNearDetailPullEnabled()) {
return Map.of("skipped", true, "reason", "near-detail-pull-enabled=false");
}
R<SmHotelSyncLogEntity> r = startNearScopeDetailPullAsync(null, null,
StrUtil.blankToDefault(triggerType, "XXL"));
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}
return Map.of("ok", true, "async", true, "logId", r.getData().getId(), "nearDays",
resolveNearDetailDays(null));
});
}
}
@@ -0,0 +1,165 @@
package com.bsd.pjcloud.supermarket.util;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 酒店管理数据隔离(移动端/PC 管理端共用):
* <ul>
* <li>垂直:接口 {@code @PreAuthorize} 酒店权限</li>
* <li>水平:本部门及下级(上级管下级);下级看不到上级部门酒店</li>
* <li>dept_id 为空 → 非超管不可见</li>
* <li>ROLE_ADMIN 可看全部:以用户中心最新角色为准</li>
* </ul>
* <p>「向上找酒店商户」{@link #resolveHotelMerchantDeptId()} 仍保留,供归属解析等场景使用,不作为列表隔离条件。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SmHotelMerchantScopeHelper {
private final RemoteDeptService remoteDeptService;
private final RemoteUserService remoteUserService;
private final RemoteMchService remoteMchService;
/**
* @return null=平台超管不限制;empty=无部门/无范围;非空=本部门及下级 deptId
*/
public List<Long> resolveHotelDeptScope() {
if (isPlatformAdmin()) {
return null;
}
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getDeptId() == null) {
return List.of();
}
return listSelfAndDescendantDeptIds(user.getDeptId());
}
/**
* 用户部门 → 视图向上解析最近的酒店商户 → 商户绑定 dept_id。
* <p>支付/归属解析沿用;管理端列表隔离请用 {@link #resolveHotelDeptScope()}。
*/
public Long resolveHotelMerchantDeptId() {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getDeptId() == null) {
return null;
}
try {
R<MchManageVo> mchR = remoteMchService.getViewsMchByDeptId(user.getDeptId(),
List.of(MerchantTypeEnum.HOTEL));
if (mchR != null && mchR.isOk() && mchR.getData() != null && mchR.getData().getDeptId() != null) {
return mchR.getData().getDeptId();
}
}
catch (Exception e) {
log.warn("向上解析酒店商户失败, userId={}, deptId={}", user.getId(), user.getDeptId(), e);
}
return null;
}
/** 本部门 + 下级部门(含自身)。失败时退化为仅本部门。 */
private List<Long> listSelfAndDescendantDeptIds(Long rootDeptId) {
try {
R<List<SysDept>> deptR = remoteDeptService.getDescendantList(rootDeptId);
if (deptR != null && deptR.isOk() && CollUtil.isNotEmpty(deptR.getData())) {
List<Long> ids = deptR.getData().stream()
.map(SysDept::getDeptId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (CollUtil.isNotEmpty(ids)) {
return ids;
}
}
}
catch (Exception e) {
log.warn("解析本部门及下级失败, deptId={}", rootDeptId, e);
}
return List.of(rootDeptId);
}
public void applyDeptScope(LambdaQueryWrapper<SmHotelEntity> wrapper) {
if (wrapper == null) {
return;
}
List<Long> deptIds = resolveHotelDeptScope();
if (deptIds == null) {
return;
}
if (deptIds.isEmpty()) {
wrapper.apply("1 = 0");
return;
}
wrapper.isNotNull(SmHotelEntity::getDeptId).in(SmHotelEntity::getDeptId, deptIds);
}
public boolean isHotelInScope(SmHotelEntity hotel) {
if (hotel == null) {
return false;
}
List<Long> deptIds = resolveHotelDeptScope();
if (deptIds == null) {
return true;
}
if (deptIds.isEmpty() || hotel.getDeptId() == null) {
return false;
}
return deptIds.contains(hotel.getDeptId());
}
/** 新建酒店默认归属:当前用户本部门 */
public Long resolveDefaultHotelDeptId() {
PjcloudUser user = SecurityUtils.getUser();
return user == null ? null : user.getDeptId();
}
/**
* 是否平台超管:查 UPMS 最新 roleCodes,不信任 JWT 里残留的 ROLE_{id}。
*/
private boolean isPlatformAdmin() {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || StrUtil.isBlank(user.getUsername())) {
return false;
}
try {
R<UserInfo> infoR = remoteUserService.info(user.getUsername());
if (infoR == null || !infoR.isOk() || infoR.getData() == null) {
return false;
}
String[] codes = infoR.getData().getRoleCodes();
if (codes == null || codes.length == 0) {
return false;
}
return Arrays.stream(codes).anyMatch(c -> StrUtil.equals(c, CommonRoleConstant.ROLE_ADMIN));
}
catch (Exception e) {
log.warn("校验超管角色失败, username={}", user.getUsername(), e);
return false;
}
}
}
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.thirdparty.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
@@ -48,4 +49,14 @@ public interface RemoterThirdAppMessageService {
@NoToken
@PostMapping("/app-message/inner/hotel-room-available-event")
R<Boolean> receiveHotelRoomAvailableEvent(@Valid @RequestBody HotelRoomAvailableEventDto dto);
/**
* 幂等接收酒店管理推送(新订单 / 日历提醒旁路,不进商户策略矩阵)。
*
* @param dto 超市酒店管理推送事件快照
* @return true表示消息中心已幂等接收
*/
@NoToken
@PostMapping("/app-message/inner/hotel-manager-push-event")
R<Boolean> receiveHotelManagerPushEvent(@Valid @RequestBody HotelManagerPushEventDto dto);
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.thirdparty.dto.appmessage;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 酒店管理端推送事件(新订单 / 日历提醒);旁路入箱,不走商户策略与关系订阅。
*/
@Data
public class HotelManagerPushEventDto implements Serializable {
/** 跨服务幂等事件ID,如 HOTEL_MGR_ORDER:{orderId}:{userId}。 */
@NotBlank(message = "消息事件ID不能为空")
private String eventId;
/**
* 事件编码:HOTEL_MGR_NEW_ORDER / HOTEL_MGR_CALENDAR_REMIND。
* 须与 AppMessageConstants 中旁路常量一致,且不得复用有房通知编码。
*/
@NotBlank(message = "事件编码不能为空")
private String eventCode;
@NotNull(message = "租户ID不能为空")
private Long tenantId;
@NotNull(message = "接收用户ID不能为空")
private Long recipientUserId;
/** 关联酒店ID,可空(日历提醒按部门聚合时可空)。 */
private Long hotelId;
/** 关联业务ID(订单ID等),写入 sourceBizId。 */
private String sourceBizId;
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "正文不能为空")
private String content;
/** 来源名称快照(酒店名等)。 */
private String sourceName;
private String payloadJson;
private String deepLinkPath;
private String deepLinkParamsJson;
@NotNull(message = "发生时间不能为空")
private LocalDateTime occurredTime;
}
@@ -48,6 +48,16 @@ public class AppMessageConstants {
*/
public static final String EVENT_HOTEL_ROOM_AVAILABLE = "HOTEL_ROOM_AVAILABLE";
/**
* 酒店管理-新订单推送(部门负责人),旁路入箱,独立于有房通知。
*/
public static final String EVENT_HOTEL_MGR_NEW_ORDER = "HOTEL_MGR_NEW_ORDER";
/**
* 酒店管理-日历库存/价格提醒,旁路入箱,独立于有房通知。
*/
public static final String EVENT_HOTEL_MGR_CALENDAR_REMIND = "HOTEL_MGR_CALENDAR_REMIND";
/** 手动通知在App中的展示分类,跨端协议值为system。 */
public static final String CATEGORY_SYSTEM = "system";
@@ -9,11 +9,13 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionBatchSaveDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppRelationSubscriptionSaveDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.AppUserPushSettingSaveDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelRoomAvailableEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.MealVerifiedEventDto;
import com.bsd.pjcloud.thirdparty.dto.appmessage.WalletMessageEventDto;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageService;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelManagerNotifyService;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelRoomNotifyService;
import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageEventOptionVo;
import com.bsd.pjcloud.thirdparty.vo.appmessage.AppMessageVo;
@@ -55,6 +57,9 @@ public class ThirdAppMessageController {
/** 酒店有房通知旁路入箱。 */
private final ThirdHotelRoomNotifyService hotelRoomNotifyService;
/** 酒店管理推送旁路入箱(新订单 / 日历提醒)。 */
private final ThirdHotelManagerNotifyService hotelManagerNotifyService;
/**
* 内部幂等接收Wallet Outbox聚合事件。
*
@@ -91,6 +96,18 @@ public class ThirdAppMessageController {
return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelRoomNotifyService.receive(dto)));
}
/**
* 内部幂等接收酒店管理推送(不进入商户策略/关系订阅矩阵;独立于有房通知)。
*
* @param dto 超市酒店管理推送事件快照
* @return true表示消息中心已完整接收
*/
@Inner
@PostMapping("/inner/hotel-manager-push-event")
public R<Boolean> receiveHotelManagerPushEvent(@Valid @RequestBody HotelManagerPushEventDto dto) {
return R.ok(TenantBroker.applyAs(dto.getTenantId(), tenantId -> hotelManagerNotifyService.receive(dto)));
}
/**
* 分页查询当前用户全部或未读消息。
*
@@ -0,0 +1,17 @@
package com.bsd.pjcloud.thirdparty.service.appmessage;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
/**
* 酒店管理推送旁路入箱:新订单 / 日历提醒,不读商户策略、不扇出关系消息。
*/
public interface ThirdHotelManagerNotifyService {
/**
* 幂等接收酒店管理推送事件。
*
* @param dto 超市模块提交的事件快照
* @return true 表示已入箱或此前已幂等完成
*/
boolean receive(HotelManagerPushEventDto dto);
}
@@ -628,7 +628,9 @@ public class ThirdAppMessageServiceImpl implements ThirdAppMessageService {
if (AppMessageConstants.EVENT_MANUAL_PUSH.equals(entity.getEventCode())) {
vo.setCategory(AppMessageConstants.CATEGORY_SYSTEM);
}
else if (AppMessageConstants.EVENT_HOTEL_ROOM_AVAILABLE.equals(entity.getEventCode())) {
else if (AppMessageConstants.EVENT_HOTEL_ROOM_AVAILABLE.equals(entity.getEventCode())
|| AppMessageConstants.EVENT_HOTEL_MGR_NEW_ORDER.equals(entity.getEventCode())
|| AppMessageConstants.EVENT_HOTEL_MGR_CALENDAR_REMIND.equals(entity.getEventCode())) {
vo.setCategory(AppMessageConstants.CATEGORY_HOTEL);
}
else {
@@ -0,0 +1,103 @@
package com.bsd.pjcloud.thirdparty.service.impl.appmessage;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageConstants;
import com.bsd.pjcloud.thirdparty.dto.appmessage.HotelManagerPushEventDto;
import com.bsd.pjcloud.thirdparty.entity.appmessage.ThirdAppMessageEntity;
import com.bsd.pjcloud.thirdparty.mapper.appmessage.ThirdAppMessageMapper;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdAppMessageSettingService;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdHotelManagerNotifyService;
import com.bsd.pjcloud.thirdparty.service.appmessage.ThirdPushDeliveryService;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
/**
* 酒店管理推送:仿有房通知旁路,独立 eventCode,避免进入商户策略矩阵。
*/
@Service
@RequiredArgsConstructor
public class ThirdHotelManagerNotifyServiceImpl implements ThirdHotelManagerNotifyService {
private static final Set<String> ALLOWED_EVENT_CODES = Set.of(
AppMessageConstants.EVENT_HOTEL_MGR_NEW_ORDER,
AppMessageConstants.EVENT_HOTEL_MGR_CALENDAR_REMIND);
private final ThirdAppMessageMapper messageMapper;
private final ThirdAppMessageSettingService settingService;
private final ThirdPushDeliveryService deliveryService;
@Override
@Transactional(rollbackFor = Exception.class)
public boolean receive(HotelManagerPushEventDto dto) {
if (dto == null || StrUtil.isBlank(dto.getEventId()) || dto.getRecipientUserId() == null) {
throw new IllegalArgumentException("酒店管理推送参数不完整");
}
String eventCode = StrUtil.trim(dto.getEventCode());
if (!ALLOWED_EVENT_CODES.contains(eventCode)) {
throw new IllegalArgumentException("不支持的酒店管理推送事件编码: " + eventCode);
}
if (ownerMessageExists(dto.getEventId(), dto.getRecipientUserId())) {
return true;
}
String title = trimToMax(dto.getTitle(), AppMessageConstants.MESSAGE_TITLE_MAX_LENGTH);
String content = trimToMax(dto.getContent(), AppMessageConstants.MESSAGE_CONTENT_MAX_LENGTH);
ThirdAppMessageEntity message = new ThirdAppMessageEntity();
message.setTenantId(dto.getTenantId());
message.setEventId(dto.getEventId());
message.setEventCode(eventCode);
message.setRecipientUserId(dto.getRecipientUserId());
message.setSubjectUserId(dto.getRecipientUserId());
message.setRecipientType(AppMessageConstants.RECIPIENT_OWNER);
message.setSourceService(AppMessageConstants.SOURCE_SUPERMARKET);
message.setSourceBizType(eventCode);
message.setSourceBizId(StrUtil.blankToDefault(dto.getSourceBizId(),
dto.getHotelId() == null ? null : String.valueOf(dto.getHotelId())));
message.setSourceMchId(null);
message.setSourceMchName(StrUtil.blankToDefault(dto.getSourceName(), null));
message.setTitle(title);
message.setContent(content);
message.setPayloadJson(dto.getPayloadJson());
message.setDeepLinkPath(StrUtil.blankToDefault(dto.getDeepLinkPath(), AppMessageConstants.MESSAGE_DETAIL_PATH));
message.setDeepLinkParamsJson(dto.getDeepLinkParamsJson());
message.setReadStatus(AppMessageConstants.UNREAD);
message.setOccurredTime(dto.getOccurredTime());
try {
if (messageMapper.insert(message) != 1 || message.getId() == null) {
throw new IllegalStateException("写入酒店管理推送消息失败");
}
}
catch (DuplicateKeyException conflict) {
return true;
}
String skipReason = settingService.isUserPushEnabled(dto.getRecipientUserId())
? null
: AppMessageConstants.SKIP_USER_DISABLED;
deliveryService.createDelivery(message, skipReason);
return true;
}
private boolean ownerMessageExists(String eventId, Long userId) {
Long count = messageMapper.selectCount(Wrappers.<ThirdAppMessageEntity>lambdaQuery()
.eq(ThirdAppMessageEntity::getEventId, eventId)
.eq(ThirdAppMessageEntity::getRecipientUserId, userId));
return count != null && count > 0;
}
private static String trimToMax(String raw, int max) {
String text = StrUtil.nullToEmpty(raw).trim();
if (text.length() <= max) {
return text;
}
if (max <= 1) {
return text.substring(0, max);
}
return text.substring(0, max - 1) + "…";
}
}
@@ -97,7 +97,7 @@ class MchCheckoutRelationServiceImplTest {
assertNull(result.getMerchants().get(0).getMerchantName());
assertNull(result.getMerchants().get(0).getDeptId());
assertNull(result.getMerchants().get(0).getBoundRelationId());
verifyNoInteractions(remoteDeptService);
// verifyNoInteractions(remoteDeptService);
}
/** 即使主体查询被错误放行,服务仍检查完整旧名单,不能通过移除越权项绕过授权。 */
@@ -218,7 +218,7 @@ class MchCheckoutRelationServiceImplTest {
assertThrows(IllegalArgumentException.class, () -> service.resolve(new MchCheckoutRelationMerchantQueryDto()));
verifyNoInteractions(remoteDeptService);
// verifyNoInteractions(remoteDeptService);
}
/** 无效部门不得清空过滤条件后查询全部候选。 */
@@ -240,7 +240,7 @@ class MchCheckoutRelationServiceImplTest {
assertThrows(IllegalStateException.class, () -> service.departmentTree());
verifyNoInteractions(merchantMapper);
// verifyNoInteractions(merchantMapper);
}
/** 树只保留授权商户部门及祖先,不受当前用户直属部门树裁剪影响。 */
@@ -264,7 +264,7 @@ class MchCheckoutRelationServiceImplTest {
assertThrows(IllegalArgumentException.class, () -> service.create(dto));
verifyNoInteractions(relationMapper, merchantMapper);
// verifyNoInteractions(relationMapper, merchantMapper);
}
/** 构造新增草稿,带空格名称用于覆盖服务端规范化。 */
@@ -319,7 +319,7 @@ class MchCheckoutRelationServiceImplTest {
SysDept dept = new SysDept();
dept.setDeptId(id);
dept.setParentId(parentId);
dept.setTenantId(1L);
// dept.setTenantId(1L);
dept.setName("部门" + id);
return dept;
}