diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index b94804393..966bce17e 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -16,6 +16,14 @@ baiyue: cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} + cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} + cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} + lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} lastleg: event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index c94b2aa41..dae585d47 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -12,6 +12,13 @@ baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABL baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} +baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} +baiyue.carrier-schedule.scheduler.cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} +baiyue.carrier-schedule.scheduler.cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} +baiyue.carrier-schedule.scheduler.cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} +baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} +baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} +baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} firstleg: inbound: allow-rejected-measurement: false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 93d9a5eb0..3de6fc532 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -99,6 +99,13 @@ services: COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} + CARRIER_SCHEDULE_CRON_MORNING: ${CARRIER_SCHEDULE_CRON_MORNING:-0 0 9 * * *} + CARRIER_SCHEDULE_CRON_NOON: ${CARRIER_SCHEDULE_CRON_NOON:-0 30 12 * * *} + CARRIER_SCHEDULE_CRON_AFTERNOON: ${CARRIER_SCHEDULE_CRON_AFTERNOON:-0 0 16 * * *} + CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} + CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} + CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} finance: <<: *common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f5309a87d..0fb4f2dfc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10253,10 +10253,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', + window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', + next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10296,14 +10296,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), - CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) +) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -10319,11 +10318,39 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_fixed_slots() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_attempt; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_attempt' + AND constraint_name='chk_carrier_schedule_attempt_no' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_attempt DROP CHECK chk_carrier_schedule_attempt_no; + END IF; +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_fixed_slots(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; + +SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); +UPDATE fl_carrier_schedule_query + SET next_allowed_at = CASE + WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR + WHEN TIME(@carrier_schedule_now) < '12:30:00' THEN DATE(@carrier_schedule_now) + INTERVAL 12 HOUR + INTERVAL 30 MINUTE + WHEN TIME(@carrier_schedule_now) < '16:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 16 HOUR + ELSE DATE(@carrier_schedule_now + INTERVAL 1 DAY) + INTERVAL 9 HOUR + END + WHERE status IN ('READY','MANUAL_REQUIRED'); + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', @@ -11928,7 +11955,7 @@ CALL queue_table_comment('baiyue_finance', 'fin_receipt_bill', '人工收款对 CALL queue_table_comment('baiyue_finance', 'fin_subject', '平台财务主体;首期仅一个有效主体'); CALL queue_table_comment('baiyue_firstleg', 'fl_assignment_history', '头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_fba_rate', '船司FBA目的仓价格版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司官网查询任务与三天窗口频控'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司固定时刻自动查询任务'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); @@ -12515,7 +12542,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前查询窗口已执行次数;最多3次'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '累计自动查询调用次数'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_scope_id', '柜次唯一作用域计算值;整票订舱查询固定为0'); @@ -12531,7 +12558,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_error_message', '最近查询失败错误说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_success_at', '最近一次取得有效结果时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'manual_reason', '需要人工查询或录入的原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '频控规则允许的下次查询时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一个09:00、12:30或16:00固定查询槽位;停止后为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'ocean_booking_id', '关联海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'official_url', '船公司官网查询入口地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query_no', '船司官网查询任务编号'); @@ -12546,14 +12573,14 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopp CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数,用于指数退避'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数诊断值;不改变固定查询槽位'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by_name', '最后修改操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前三天查询窗口起始时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '首次进入自动查询调度的时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); @@ -12568,7 +12595,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_id', '执行本次尝试的操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前三天窗口内的尝试序号;取值1至3'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '任务累计自动查询调用序号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index ea318b6a5..467b7bf9a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -51,7 +51,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 仅在 `Asia/Shanghai` 每日 09:00、12:30、16:00 三个固定槽位自动查询;本轮结果无论成功、无变化或失败都进入下一固定槽位,不额外补跑或指数退避,实际 `EMPTY_RETURNED` 后停止;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java index 28c72c2a1..5e3a0ebe1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Component; import java.time.Clock; import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.UUID; @@ -26,6 +27,9 @@ public class CarrierScheduleQueryScheduler { @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20; + @Value("${baiyue.carrier-schedule.scheduler.max-tasks-per-trigger:2000}") + private int maxTasksPerTrigger = 2000; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; @Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}") private Duration leaseDuration = Duration.ofMinutes(5); @@ -35,10 +39,27 @@ public class CarrierScheduleQueryScheduler { this.repository = repository; this.service = service; this.integration = integration; } - @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") public void poll() { - LocalDateTime now = LocalDateTime.now(clock); - repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now)); + LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); + int batch = Math.max(1, Math.min(batchSize, 100)); + int safetyLimit = Math.max(1, maxTasksPerTrigger); + int scanned = 0; + while (scanned < safetyLimit) { + List candidates = repository.due(triggerAt, + Math.min(batch, safetyLimit - scanned)); + if (candidates.isEmpty()) return; + for (CarrierScheduleQueryEntity candidate : candidates) { + execute(candidate, triggerAt); scanned++; + if (scanned >= safetyLimit) return; + } + if (candidates.size() < batch) return; + } } /** Executes only after this worker wins the database lease. */ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 69315989c..94983631d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -43,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HexFormat; import java.util.List; import java.util.Locale; @@ -51,14 +53,14 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { - static final int MAX_ATTEMPTS = 3; - static final int WINDOW_DAYS = 3; private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; private Clock clock = Clock.systemDefaultZone(); public List carriers() { return adapters.descriptors(); } @@ -114,7 +116,8 @@ public class CarrierScheduleQueryService { value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); - value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } catch (DuplicateKeyException exception) { @@ -206,7 +209,7 @@ public class CarrierScheduleQueryService { value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜"); value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); } else { - value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now())); } if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); return true; @@ -224,31 +227,13 @@ public class CarrierScheduleQueryService { value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); - if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { - value.setStatus("READY"); - value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); - value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); - } else { - int failures = count(value.getConsecutiveFailures()) + 1; - LocalDateTime windowStart = value.getWindowStartedAt(); - if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - windowStart = now; value.setAttemptCount(0); - } - int attempts = count(value.getAttemptCount()) + 1; - value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); - value.setConsecutiveFailures(failures); - if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { - value.setStatus("MANUAL_REQUIRED"); - value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); - } else { - long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); - LocalDateTime retryAt = now.plusMinutes(minutes); - if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - retryAt = windowStart.plusDays(WINDOW_DAYS); - } - value.setNextAllowedAt(retryAt); - } - } + boolean success = "SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome); + value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) + ? "MANUAL_REQUIRED" : "READY"); + value.setConsecutiveFailures(success ? 0 : increment(value.getConsecutiveFailures())); + value.setAttemptCount(increment(value.getAttemptCount())); + if (value.getWindowStartedAt() == null) value.setWindowStartedAt(now); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); CarrierScheduleQueryEntity after = required(id); addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, @@ -259,7 +244,7 @@ public class CarrierScheduleQueryService { String errorCode, String errorMessage) { CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); - attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1))); + attempt.setWindowAttemptNo(increment(value.getAttemptCount())); attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); boolean manual = "MANUAL_REQUIRED".equals(outcome); @@ -287,7 +272,8 @@ public class CarrierScheduleQueryService { value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); value.setStatus("READY"); value.setTriggerSource("MANUAL"); value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); - value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } @@ -314,7 +300,7 @@ public class CarrierScheduleQueryService { LocalDateTime now = now(); value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); - value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null); if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); @@ -505,8 +491,9 @@ public class CarrierScheduleQueryService { return normalized; } - private LocalDateTime now() { return LocalDateTime.now(clock); } - private static int count(Integer value) { return value == null ? 0 : value; } + private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); } + private static int increment(Integer value) { return value == null || value < 0 ? 1 + : value == Integer.MAX_VALUE ? Integer.MAX_VALUE : value + 1; } private static void validateKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java new file mode 100644 index 000000000..9165c9714 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.portschedule.service; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +public final class CarrierScheduleQuerySlots { + private static final LocalTime MORNING = LocalTime.of(9, 0); + private static final LocalTime NOON = LocalTime.of(12, 30); + private static final LocalTime AFTERNOON = LocalTime.of(16, 0); + + private CarrierScheduleQuerySlots() { } + + /** Returns the first fixed slot strictly after {@code now}. Exact slot boundaries advance. */ + public static LocalDateTime nextSlot(LocalDateTime now) { + if (now.toLocalTime().isBefore(MORNING)) return now.toLocalDate().atTime(MORNING); + if (now.toLocalTime().isBefore(NOON)) return now.toLocalDate().atTime(NOON); + if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON); + return now.toLocalDate().plusDays(1).atTime(MORNING); + } +} diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index e3aa7af0b..226cc98ae 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -33,6 +33,14 @@ baiyue: cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} + cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} + cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} + lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index c9062effe..7a9ec3fc5 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -21,10 +21,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', + window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', + next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -64,14 +64,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), - CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) +) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -87,7 +86,6 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 1788868a2..2a8b46bed 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -25,7 +25,7 @@ paths: - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} responses: - '200': {description: Query tasks with source, freshness and rate-limit fields} + '200': {description: Query tasks with source and freshness fields; automatic execution uses the fixed Shanghai slots} post: summary: Create a query task linked to a real booking and allocated container x-permission: platform:firstleg:port-schedule-query:create diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index ffb9c84fd..d707b84f7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -19,8 +19,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") - .contains("attempt_count BETWEEN 0 AND 3") - .contains("window_attempt_no BETWEEN 1 AND 3") + .contains("累计自动查询调用次数") + .contains("下一个09:00、12:30或16:00固定查询槽位") + .doesNotContain("attempt_count BETWEEN 0 AND 3") + .doesNotContain("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") @@ -61,6 +63,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("uk_carrier_schedule_query_scope") .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") + .contains("migrate_carrier_schedule_fixed_slots") + .contains("DROP CHECK chk_carrier_schedule_query_attempt") + .contains("DROP CHECK chk_carrier_schedule_attempt_no") + .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("船司查询任务角色授权未完整创建") .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java index 6b4c92c6e..fd95bc508 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -4,18 +4,81 @@ import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class CarrierScheduleQuerySchedulerTest { + @Test + void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception { + Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class); + assertThat(schedules).hasSize(3); + assertThat(schedules).extracting(Scheduled::cron).containsExactlyInAnyOrder( + "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", + "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", + "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}"); + assertThat(schedules).extracting(Scheduled::zone) + .containsOnly("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}"); + } + + @Test + void drainsControlledBatchesAndStopsAtThePerTriggerSafetyLimit() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + CarrierScheduleQueryEntity first = task(1L); CarrierScheduleQueryEntity second = task(2L); + CarrierScheduleQueryEntity third = task(3L); + when(repository.due(any(), anyInt())).thenReturn(List.of(first, second), List.of(third)); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 3); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T01:00:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 2); + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 1); + verify(scheduler, times(3)).execute(any(), any()); + } + + @Test + void keepsClaimingControlledBatchesUntilNoDueTasksRemain() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + when(repository.due(any(), anyInt())).thenReturn(List.of(task(1L), task(2L)), List.of()); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 10); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T04:30:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository, times(2)).due(LocalDateTime.of(2026, 8, 21, 12, 30), 2); + verify(scheduler, times(2)).execute(any(), any()); + } + @Test void leaseWinnerPersistsStandardNodesAndCompletesAttempt() { CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); @@ -42,4 +105,8 @@ class CarrierScheduleQuerySchedulerTest { verify(service).applyProviderException(anyLong(), any()); verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } + + private static CarrierScheduleQueryEntity task(long id) { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 9930d54c5..70d143cc3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -71,7 +71,9 @@ class CarrierScheduleQueryServiceTest { assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); - verify(repository).insert(any(CarrierScheduleQueryEntity.class)); + ArgumentCaptor created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class); + verify(repository).insert(created.capture()); + assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); } @Test @@ -90,6 +92,7 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } @@ -212,7 +215,31 @@ class CarrierScheduleQueryServiceTest { verify(repository).insert(attempt.capture()); assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE"); assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler"); + assertThat(attempt.getValue().getWindowAttemptNo()).isEqualTo(1); assertThat(task.getStatus()).isEqualTo("READY"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + } + + @Test + void everySchedulerOutcomeUsesTheNextFixedSlotWithoutExtraRetryTimes() { + List outcomes = List.of("SUCCESS", "NO_CHANGE", "MANUAL_REQUIRED", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE"); + List tasks = outcomes.stream().map(value -> task(0, 0)).toList(); + when(repository.lock(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.find(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null)); + + assertThat(tasks).allSatisfy(value -> { + assertThat(value.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(value.getAttemptCount()).isEqualTo(1); + }); + assertThat(tasks.get(0).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(1).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(tasks.get(3).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED"); } @Test @@ -291,7 +318,7 @@ class CarrierScheduleQueryServiceTest { value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L); value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM"); value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053"); - value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA"); + value.setOfficialUrl("https://api.zim.com/"); value.setAccessMode("OFFICIAL_API"); value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1)); value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version); return value; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java new file mode 100644 index 000000000..31e276927 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.portschedule.service; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQuerySlotsTest { + @Test + void returnsTheFirstStrictlyLaterShanghaiSlotAtExactBoundaries() { + assertThat(CarrierScheduleQuerySlots.nextSlot(at(8, 59, 59))).isEqualTo(at(9, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(9, 0, 0))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 29, 59))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 30, 0))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0))) + .isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0)); + } + + private static LocalDateTime at(int hour, int minute, int second) { + return LocalDateTime.of(2026, 8, 21, hour, minute, second); + } +} diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index d040f1727..d89e29f2d 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -120,10 +120,9 @@ export async function run(context) { const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '查询任务初始来源、频控和过期字段稳定', createResponse, - Boolean(queryId) && created?.query?.query?.status === 'READY' && created?.query?.query?.attemptCount === 0 - && created?.query?.maxAttempts === 3 && created?.query?.expired === true - && Boolean(created?.query?.nextAllowedAt) && created?.query?.query?.accessMode === 'HCAPTCHA', + context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse, + Boolean(queryId) && created?.query?.query?.status === 'READY' + && created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API', `queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS) if (!queryId) return @@ -157,7 +156,7 @@ export async function run(context) { context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) - && Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false + && completed?.query?.expired === false && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS)