酒店任务优化

This commit is contained in:
zjf committed 2026-11-14 00:51:30 +08:00
1 parent 40384b7dc4
commit 89c0940daa
9 files changed
+275 -22

No files matched your search

@@ -95,9 +95,15 @@ public class SmHotelDownstreamChannelConfigDTO {
@Schema(description = "推送流水保留天数,默认 15;≤0 不清理")
private Integer pushLogRetainDays;
@Schema(description = "是否参与资源日更定时推送;false=仅手动/变更推,默认 false")
@Schema(description = "是否参与资源定时推送;false=仅手动/变更推,默认 false")
private Boolean resourcePushJoinSchedule;
@Schema(description = "资源定时推:每天最多启动次数,默认 1")
private Integer resourcePushTimesPerDay;
@Schema(description = "资源定时推:两次最小间隔小时,默认 12;≤0 不校验间隔")
private Integer resourcePushMinIntervalHours;
@Schema(description = "是否参与近窗详情定时;false=仅手动近窗刷新,默认 false")
private Boolean nearDetailPullJoinSchedule;
@@ -82,11 +82,32 @@ public class SmHotelShangyouChannelConfigDTO {
private String detailPullDailyCron;
/** 全量详情前是否先确保今日列表同步成功;默认 true */
private Boolean detailPullEnsureHotelListFirst;
/**
* 全量+范围共用:每天最多几次(上游文档配额)。
* 与 fullPriceSyncMaxPerDay 同义;YAML 写 detail-pull-times-per-day 即可。
*/
private Integer detailPullTimesPerDay;
/**
* 全量+范围共用:两次最小间隔小时。
* 与 fullPriceSyncMinIntervalHours 同义;YAML 写 detail-pull-min-interval-hours 即可。
*/
private Integer detailPullMinIntervalHours;
/** 渠道范围合集全量详情定时(各下游范围并集,跳过本地) */
private Boolean scopeDetailPullDailyEnabled;
private String scopeDetailPullDailyCron;
/** 范围合集定时:每天最多启动次数(另受上游共享配额) */
private Integer scopeDetailPullTimesPerDay;
/** 范围合集定时:两次启动最小间隔小时 */
private Integer scopeDetailPullMinIntervalHours;
/** 近窗详情调度总开关(扫描 join 伙伴;不占全量配额) */
private Boolean nearDetailPullEnabled;
@@ -156,15 +156,29 @@ public class HotelDownstreamProperties {
private int resourceAutoPushDebounceSeconds = 90;
/**
* 是否参与资源日更定时推送(授权范围全量:可售/关房/跳过)。
* false(默认):不进日更;确认范围与限流后再在伙伴 YAML 开 true。
* 是否参与资源定时推送(授权范围全量:可售/关房/跳过)。
* false(默认):不进定时推;确认范围与限流后再在伙伴 YAML 开 true。
* 推荐与近窗拆开:近窗/范围只负责拉上游,本开关负责推下游。
*/
private boolean resourcePushJoinSchedule = false;
/**
* 本伙伴资源定时推:每天最多启动次数。例:2=一天推两趟。
* 仅 join-schedule=true 时生效;≤0 按 1。
*/
private int resourcePushTimesPerDay = 1;
/**
* 本伙伴资源定时推:两次启动最小间隔小时数。例:12=至少隔 12 小时。
* ≤0 表示不校验间隔(仍受 times-per-day 限制)。
*/
private int resourcePushMinIntervalHours = 12;
/**
* 是否参与「近窗详情」定时刷新(只刷本伙伴授权范围内、未来 N 天房价;不占全量配额)。
* false(默认):不进近窗定时,可 PC 手动「近窗刷新」;
* true:全局近窗时钟到点后,按 {@link #nearDetailPullIntervalHours} 判断是否该刷这一家。
* 推荐:只拉上游;推送用 resource-push-join-schedule / resource-auto-push。
*/
private boolean nearDetailPullJoinSchedule = false;
@@ -181,16 +195,19 @@ public class HotelDownstreamProperties {
/**
* 近窗详情是否边拉边推:单店拉上游成功后立刻推本伙伴(拉一家推一家)。
* 仅对「单伙伴近窗」任务生效;开放应用(拉模式)请保持 false。
* 上万店时很慢(并发强制 1);推荐 false,改为「近窗只拉 + 资源定时推」。
* false(默认):只刷本地。
*/
private boolean nearDetailPullThenPush = false;
/** 进程级:是否跑资源日更时钟;改 cron 需重启 */
/** 进程级:是否跑资源推送扫描时钟;改 cron 需重启 */
private boolean resourcePushScheduleEnabled = true;
/** 资源日更 cron(默认每天 02:30) */
private String resourcePushCron = "0 30 2 * * ?";
/**
* 资源推送扫描时钟 cron(默认每小时 :30 扫一轮 join 伙伴;是否真推看各家 times/interval)。
* 正式环境优先改 Quartz sys_job,本地 Spring 兜底才读本项。
*/
private String resourcePushCron = "0 30 * * * ?";
/**
* 下游全量推酒店并发。1=串行。
@@ -172,28 +172,48 @@ public class ShangyouHotelProperties {
private boolean localDailyRollEnabled = true;
/**
* 上游渠道「全量详情」定时开关(拉该渠道全部启用酒店;受 full-price-sync 次数/间隔限制)。
* 上游渠道「全量详情」定时开关(拉该渠道全部启用酒店)。
* 真正到点是否开跑还受:先列表成功、每日次数、最小间隔、上游 full-price 配额。
* 默认关闭,按需在渠道配置打开。
*/
private boolean detailPullDailyEnabled = false;
/**
* 全量详情 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 04:00:0 0 4 * * ?(注意与上游 6 小时间隔、每日次数上限)
* 全量详情调度时钟 Cron(仅本地 Spring 兜底;正式走 Quartz 同名任务)。
* 默认每小时扫一次:到点只判断「该不该跑」,不是「一到就必跑满配额」。
* Quartz 侧默认同样按小时扫,次数/间隔见 full-price-sync-* / detail-pull-times-*。
*/
private String detailPullDailyCron = "0 0 4 * * ?";
private String detailPullDailyCron = "0 0 * * * ?";
/**
* 「渠道范围合集」全量详情定时开关:只拉全部下游范围映射并集中的上游酒店(跳过本地)。
* 全量详情定时:是否要求「今日已有成功的酒店列表同步」才开跑。
* true(默认):没有则先启动列表同步,本轮详情跳过,下一时钟再试。
*/
private boolean detailPullEnsureHotelListFirst = true;
/**
* 「渠道范围合集」详情定时开关:只拉全部下游范围映射并集中的上游酒店(跳过本地)。
* 与全量详情分开开关;建议生产开这个、全量详情保持关(范围更小、更贴下游)。
* 默认关闭。
*/
private boolean scopeDetailPullDailyEnabled = false;
/**
* 渠道范围合集详情 Cron(仅 spring-schedule-enabled=true 时生效)。
* 默认每天 05:00:0 0 5 * * ?
* 范围合集详情调度时钟 Cron(仅本地 Spring 兜底;正式走 Quartz)。
* 默认每小时 :20 扫一次;真正间隔/次数见 scope-detail-pull-times-per-day 等。
*/
private String scopeDetailPullDailyCron = "0 0 5 * * ?";
private String scopeDetailPullDailyCron = "0 20 * * * ?";
/**
* 范围合集定时:每天最多启动次数(另受下方上游 full-price 共享配额)。
* 例:2=一天最多开两趟范围详情。
*/
private int scopeDetailPullTimesPerDay = 1;
/**
* 范围合集定时:两次启动最小间隔小时数。例:6=至少隔 6 小时。
*/
private int scopeDetailPullMinIntervalHours = 6;
/**
* 近窗详情定时总开关:到点后扫描「near-detail-pull-join-schedule=true」的下游伙伴,
@@ -215,10 +235,16 @@ public class ShangyouHotelProperties {
*/
private String nearDetailPullCron = "0 */15 * * * ?";
/** 文档:全量房价房量每天最多次数 */
/**
* 上游文档:全量房价房量每天最多次数(全量详情 + 范围合集详情共用;近窗不占)。
* YAML 也可写 detail-pull-times-per-day(同义覆盖本字段)。
*/
private int fullPriceSyncMaxPerDay = 3;
/** 文档:全量房价两次间隔最少小时数 */
/**
* 上游文档:全量房价两次间隔最少小时数(全量 + 范围合集共用;近窗不占)。
* YAML 也可写 detail-pull-min-interval-hours(同义覆盖本字段)。
*/
private int fullPriceSyncMinIntervalHours = 6;
/** 全量拉详情:每家酒店间隔毫秒(防打爆上游;并行时主要靠 price-api-qps,默认 0) */
@@ -65,12 +65,18 @@ public final class ShangyouHotelApi {
/** 列表同步/差集导入所选数据来源(字典值) */
public static final String REDIS_IMPORT_DATA_SOURCE = "supermarket:shangyou:hotel:import:dataSource";
/** 今日全量房价同步次数 */
/** 今日全量房价同步次数(全量详情+范围合集共用上游配额) */
public static final String REDIS_FULL_PRICE_SYNC_COUNT = "supermarket:shangyou:hotel:fullprice:count";
/** 上次全量房价同步时间 epochMs */
public static final String REDIS_FULL_PRICE_SYNC_LAST = "supermarket:shangyou:hotel:fullprice:last";
/** 范围合集定时:今日已启动次数(业务节奏,另受 fullprice 配额) */
public static final String REDIS_SCOPE_DETAIL_SCHED_COUNT = "supermarket:shangyou:hotel:scope-detail:sched:count";
/** 范围合集定时:上次启动 epochMs */
public static final String REDIS_SCOPE_DETAIL_SCHED_LAST = "supermarket:shangyou:hotel:scope-detail:sched:last";
/** 房价接口 QPS 秒窗计数前缀,完整 key = 前缀 + ":" + epochSecond */
public static final String REDIS_PRICE_QPS_PREFIX = "supermarket:shangyou:hotel:price:qps";
}
@@ -394,6 +394,12 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (dto.getResourcePushJoinSchedule() != null) {
target.setResourcePushJoinSchedule(dto.getResourcePushJoinSchedule());
}
if (dto.getResourcePushTimesPerDay() != null && dto.getResourcePushTimesPerDay() > 0) {
target.setResourcePushTimesPerDay(dto.getResourcePushTimesPerDay());
}
if (dto.getResourcePushMinIntervalHours() != null) {
target.setResourcePushMinIntervalHours(dto.getResourcePushMinIntervalHours());
}
if (dto.getNearDetailPullJoinSchedule() != null) {
target.setNearDetailPullJoinSchedule(dto.getNearDetailPullJoinSchedule());
}
@@ -514,12 +520,27 @@ public class SmHotelChannelConfigServiceImpl extends ServiceImpl<SmHotelChannelC
if (StrUtil.isNotBlank(dto.getDetailPullDailyCron())) {
target.setDetailPullDailyCron(dto.getDetailPullDailyCron().trim());
}
if (dto.getDetailPullEnsureHotelListFirst() != null) {
target.setDetailPullEnsureHotelListFirst(dto.getDetailPullEnsureHotelListFirst());
}
if (dto.getDetailPullTimesPerDay() != null && dto.getDetailPullTimesPerDay() > 0) {
target.setFullPriceSyncMaxPerDay(dto.getDetailPullTimesPerDay());
}
if (dto.getDetailPullMinIntervalHours() != null && dto.getDetailPullMinIntervalHours() >= 0) {
target.setFullPriceSyncMinIntervalHours(dto.getDetailPullMinIntervalHours());
}
if (dto.getScopeDetailPullDailyEnabled() != null) {
target.setScopeDetailPullDailyEnabled(dto.getScopeDetailPullDailyEnabled());
}
if (StrUtil.isNotBlank(dto.getScopeDetailPullDailyCron())) {
target.setScopeDetailPullDailyCron(dto.getScopeDetailPullDailyCron().trim());
}
if (dto.getScopeDetailPullTimesPerDay() != null && dto.getScopeDetailPullTimesPerDay() > 0) {
target.setScopeDetailPullTimesPerDay(dto.getScopeDetailPullTimesPerDay());
}
if (dto.getScopeDetailPullMinIntervalHours() != null && dto.getScopeDetailPullMinIntervalHours() >= 0) {
target.setScopeDetailPullMinIntervalHours(dto.getScopeDetailPullMinIntervalHours());
}
if (dto.getNearDetailPullEnabled() != null) {
target.setNearDetailPullEnabled(dto.getNearDetailPullEnabled());
}
@@ -25,6 +25,7 @@ import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -47,6 +48,12 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
private static final String TAG = "下游自动推送";
/** 资源定时推:各伙伴上次启动 epochMs */
private static final String RESOURCE_PUSH_LAST_KEY = "sm:hotel:resource-push:last:";
/** 资源定时推:各伙伴今日启动次数 */
private static final String RESOURCE_PUSH_COUNT_KEY = "sm:hotel:resource-push:count:";
private final HotelDownstreamProperties yamlDefaults;
private final SmHotelChannelConfigService channelConfigService;
@@ -63,10 +70,13 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
private final SmHotelDownstreamPushLogService downstreamPushLogService;
private final StringRedisTemplate redisTemplate;
public SmHotelDownstreamAutoPushServiceImpl(HotelDownstreamProperties yamlDefaults,
SmHotelChannelConfigService channelConfigService, SmHotelChannelDeptService channelDeptService,
SmHotelMapper hotelMapper, SmHotelDailyMapper dailyMapper, @Lazy SmHotelMidService midService,
@Lazy SmHotelSyncService syncService, SmHotelDownstreamPushLogService downstreamPushLogService) {
@Lazy SmHotelSyncService syncService, SmHotelDownstreamPushLogService downstreamPushLogService,
StringRedisTemplate redisTemplate) {
this.yamlDefaults = yamlDefaults;
this.channelConfigService = channelConfigService;
this.channelDeptService = channelDeptService;
@@ -75,6 +85,7 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
this.midService = midService;
this.syncService = syncService;
this.downstreamPushLogService = downstreamPushLogService;
this.redisTemplate = redisTemplate;
}
private final ConcurrentHashMap<Long, ScheduledFuture<?>> pendingByHotel = new ConcurrentHashMap<>();
@@ -272,12 +283,20 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
}
for (String partner : partners) {
try {
String dueErr = checkResourcePushScheduleDue(partner);
if (dueErr != null) {
log.debug("|{}|定时推跳过|partner={}|reason={}|", TAG, partner, dueErr);
continue;
}
var started = syncService.startDownstreamScopePushAsync(partner, "SCHEDULE");
log.info("|{}|日更已启动|partner={}|msg={}|", TAG, partner,
if (started != null && started.getCode() == 0) {
markResourcePushScheduleStarted(partner);
}
log.info("|{}|定时推已启动|partner={}|msg={}|", TAG, partner,
started == null ? null : started.getMsg());
}
catch (Exception e) {
log.warn("|{}|日更启动失败|partner={}|err={}|", TAG, partner, e.getMessage());
log.warn("|{}|定时推启动失败|partner={}|err={}|", TAG, partner, e.getMessage());
}
}
});
@@ -296,6 +315,53 @@ public class SmHotelDownstreamAutoPushServiceImpl implements SmHotelDownstreamAu
+ "大批量补数可在该伙伴 YAML 临时设 resource-auto-push-enabled: false";
}
/** 伙伴资源定时推:次数 + 间隔未到则返回原因。 */
private String checkResourcePushScheduleDue(String partner) {
HotelDownstreamProperties ds = channelConfigService.resolveDownstreamRuntime(partner);
int times = ds == null ? 1 : Math.max(1, ds.getResourcePushTimesPerDay());
int minHours = ds == null ? 12 : ds.getResourcePushMinIntervalHours();
String last = redisTemplate.opsForValue().get(RESOURCE_PUSH_LAST_KEY + partner);
if (StrUtil.isNotBlank(last) && minHours > 0) {
try {
long lastMs = Long.parseLong(last.trim());
long gap = System.currentTimeMillis() - lastMs;
if (gap < minHours * 3600_000L) {
long leftMin = (minHours * 3600_000L - gap) / 60_000L + 1;
return "not-due:距上次不足 " + minHours + "h,约再等 " + leftMin + " 分钟";
}
}
catch (NumberFormatException ignored) {
// ignore
}
}
String countStr = redisTemplate.opsForValue().get(RESOURCE_PUSH_COUNT_KEY + partner);
int count = 0;
if (StrUtil.isNotBlank(countStr)) {
try {
count = Integer.parseInt(countStr.trim());
}
catch (NumberFormatException ignored) {
count = 0;
}
}
if (count >= times) {
return "times-reached:今日已达 " + times + " 次";
}
return null;
}
private void markResourcePushScheduleStarted(String partner) {
if (StrUtil.isBlank(partner)) {
return;
}
redisTemplate.opsForValue().set(RESOURCE_PUSH_LAST_KEY + partner,
String.valueOf(System.currentTimeMillis()), 2, TimeUnit.DAYS);
Long c = redisTemplate.opsForValue().increment(RESOURCE_PUSH_COUNT_KEY + partner);
if (c != null && c == 1L) {
redisTemplate.expire(RESOURCE_PUSH_COUNT_KEY + partner, 1, TimeUnit.DAYS);
}
}
private void scheduleDebounced(Long hotelId) {
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) {
@@ -2920,6 +2920,68 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
done.setStageMsg(StrUtil.maxLength("完成,成功见 successCount,失败明细见 failDetail", 500));
}
private boolean hasRunningListSync() {
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_LIST)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING));
return running != null && running > 0;
}
/** 今日是否已有成功的酒店列表同步(全量详情的前置条件)。 */
private boolean hasSuccessfulListSyncToday() {
LocalDateTime start = LocalDate.now().atStartOfDay();
Long ok = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_LIST)
.eq(SmHotelSyncLogEntity::getStatus, STATUS_SUCCESS)
.ge(SmHotelSyncLogEntity::getCreateTime, start));
return ok != null && ok > 0;
}
/**
* 范围合集业务节奏:每天次数 + 最小间隔(与上游 full-price 配额分开记;真正开跑仍走 checkFullPriceSyncQuota)。
*/
private String checkScopeDetailScheduleDue() {
int maxPerDay = Math.max(1, props().getScopeDetailPullTimesPerDay());
int minHours = Math.max(0, props().getScopeDetailPullMinIntervalHours());
String last = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_SCOPE_DETAIL_SCHED_LAST);
if (StrUtil.isNotBlank(last) && minHours > 0) {
try {
long lastMs = Long.parseLong(last.trim());
long gap = System.currentTimeMillis() - lastMs;
if (gap < minHours * 3600_000L) {
long leftMin = (minHours * 3600_000L - gap) / 60_000L + 1;
return "scope-detail-not-due:距上次不足 " + minHours + "h,约再等 " + leftMin + " 分钟";
}
}
catch (NumberFormatException ignored) {
// ignore
}
}
String countStr = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_SCOPE_DETAIL_SCHED_COUNT);
int count = 0;
if (StrUtil.isNotBlank(countStr)) {
try {
count = Integer.parseInt(countStr.trim());
}
catch (NumberFormatException ignored) {
count = 0;
}
}
if (count >= maxPerDay) {
return "scope-detail-times-reached:今日已达 " + maxPerDay + " 次";
}
return null;
}
private void markScopeDetailScheduleStarted() {
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_SCOPE_DETAIL_SCHED_LAST,
String.valueOf(System.currentTimeMillis()), 2, TimeUnit.DAYS);
Long c = redisTemplate.opsForValue().increment(ShangyouHotelApi.REDIS_SCOPE_DETAIL_SCHED_COUNT);
if (c != null && c == 1L) {
redisTemplate.expire(ShangyouHotelApi.REDIS_SCOPE_DETAIL_SCHED_COUNT, 1, TimeUnit.DAYS);
}
}
/** 文档限制:全量房价每天次数 + 最小间隔 */
private String checkFullPriceSyncQuota() {
int maxPerDay = Math.max(1, props().getFullPriceSyncMaxPerDay());
@@ -3314,6 +3376,29 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return Map.of("skipped", true, "reason", "detail-pull-daily-enabled=false");
}
String ds = StrUtil.blankToDefault(props().getDefaultImportDataSource(), SmHotelDataSource.SHANGYOU);
// 全量详情前提:先有今日成功的酒店列表;否则本轮先拉列表,详情等下一时钟
if (props().isDetailPullEnsureHotelListFirst()) {
if (hasRunningListSync()) {
return Map.of("skipped", true, "reason", "hotel-list-busy");
}
if (!hasSuccessfulListSyncToday()) {
if (!props().isHotelListDailySyncEnabled()) {
return Map.of("skipped", true, "reason",
"ensure-hotel-list-first 但 hotel-list-daily-sync-enabled=false,且今日无成功列表");
}
R<SmHotelSyncLogEntity> listR = startListSyncAsync(props().isAutoImportNewHotels(), ds,
StrUtil.blankToDefault(triggerType, "XXL"));
Map<String, Object> m = new LinkedHashMap<>(8);
m.put("skipped", true);
m.put("reason", "ensure-hotel-list-first");
m.put("listStarted", listR != null && listR.getCode() == 0);
m.put("listMsg", listR == null ? "null" : listR.getMsg());
if (listR != null && listR.getCode() == 0 && listR.getData() != null) {
m.put("listLogId", listR.getData().getId());
}
return m;
}
}
R<SmHotelSyncLogEntity> r = startDetailPullAsync(null, StrUtil.blankToDefault(triggerType, "XXL"), ds);
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
@@ -3334,10 +3419,15 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
if (!props().isScopeDetailPullDailyEnabled()) {
return Map.of("skipped", true, "reason", "scope-detail-pull-daily-enabled=false");
}
String dueErr = checkScopeDetailScheduleDue();
if (dueErr != null) {
return Map.of("skipped", true, "reason", dueErr);
}
R<SmHotelSyncLogEntity> r = startScopeUnionDetailPullAsync(StrUtil.blankToDefault(triggerType, "XXL"));
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}
markScopeDetailScheduleStarted();
return Map.of("ok", true, "async", true, "logId", r.getData().getId());
});
}
@@ -167,7 +167,7 @@ spring:
# resource-auto-push-enabled: true # 本地/回调变更后防抖自动推
# resource-auto-push-debounce-seconds: 90
# resource-push-schedule-enabled: true # 日更总开关
# resource-push-cron: 0 30 2 * * ? # 日更时钟;伙伴 YAML 另开 resource-push-join-schedule
# resource-push-cron: 0 30 * * * ? # 资源推扫描时钟(每小时);伙伴 join + times/interval
# # --- 推送流水(伙伴 YAML 可覆盖 mode/截断/保留天)---
# push-log-mode: fail_only # fail_only|sample|all
# push-log-sample-success-max: 20