diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 966bce17e..910720455 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -18,9 +18,8 @@ baiyue: 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 * * *} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} 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} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index dae585d47..1123e5927 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -13,9 +13,8 @@ baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_E 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.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} +baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} 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} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3de6fc532..385efc81b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -100,9 +100,8 @@ services: 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_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M} + CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S} 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} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e120a9295..e06944ed6 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -58,6 +58,8 @@ 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 +执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。 + 执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 ## 三期分柜、预装、拣货与装柜当前基线 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0fb4f2dfc..8afc2bb8f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10115,7 +10115,7 @@ BEGIN OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' - AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN + AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' @@ -10130,7 +10130,7 @@ BEGIN AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' OR p.permission_code LIKE 'platform:firstleg:customs:%' OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' - OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 38 THEN + OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; END IF; END$$ @@ -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 '累计自动查询调用次数', - next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', + 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 '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10296,13 +10296,14 @@ 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')) -) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; + '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='六家船公司固定槽位与技术退避查询任务'; 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 '任务累计自动查询调用序号', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -10318,39 +10319,11 @@ 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', @@ -10568,6 +10541,138 @@ DELIMITER ; CALL assert_carrier_schedule_query_schema(); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_retry_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='trigger_source') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' + COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_standard_node') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_node_observed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stopped_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stopped_at DATETIME(3) NULL COMMENT '任务停止时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stop_reason') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stop_reason VARCHAR(500) NULL COMMENT '任务停止原因'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_owner') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_until') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='consecutive_failures') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='window_started_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN window_started_at DATETIME(3) NULL COMMENT '当前正常查询槽位时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='attempt_count') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='next_allowed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due') THEN + ALTER TABLE fl_carrier_schedule_query + ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id); + END IF; +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_retry_columns(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; + +SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); +SET @carrier_schedule_next = 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; +UPDATE fl_carrier_schedule_query + SET next_allowed_at=@carrier_schedule_next,window_started_at=@carrier_schedule_next, + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status IN ('READY','MANUAL_REQUIRED'); +UPDATE fl_carrier_schedule_query + SET next_allowed_at=NULL, + window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status='STOPPED'; +UPDATE fl_carrier_schedule_query + SET window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=LEAST(COALESCE(attempt_count,0),3), + consecutive_failures=COALESCE(consecutive_failures,0); + +ALTER TABLE fl_carrier_schedule_query + MODIFY COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + MODIFY COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + MODIFY COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数', + MODIFY COLUMN window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + MODIFY COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次', + MODIFY COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; +DELIMITER $$ +CREATE PROCEDURE ensure_carrier_schedule_retry_check() +BEGIN + IF NOT 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 + ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3); + END IF; +END$$ +DELIMITER ; +CALL ensure_carrier_schedule_retry_check(); +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; + -- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql -- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 @@ -11955,7 +12060,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', '船司查询写操作幂等与请求摘要'); @@ -12542,7 +12647,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', '累计自动查询调用次数'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前正常查询槽位已调用次数;最多3次'); 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'); @@ -12558,7 +12663,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', '下一个09:00、12:30或16:00固定查询槽位;停止后为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一正常槽位或槽内技术退避时间;停止后为空'); 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', '船司官网查询任务编号'); @@ -12573,14 +12678,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', '本次尝试的船公司编码快照'); @@ -12595,7 +12700,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', '任务累计自动查询调用序号'); +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_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', '变更前任务数据快照;创建时为空'); @@ -14598,6 +14703,22 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', + 'stopped_at','stop_reason','lease_owner','lease_until','consecutive_failures', + 'window_started_at','attempt_count','next_allowed_at')) <> 11 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due' + AND column_name IN ('status','next_allowed_at','id')) <> 3 + OR (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' + AND constraint_type='CHECK') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整'; + END IF; IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema IN ('baiyue_auth','baiyue_iam','baiyue_order','baiyue_warehouse', @@ -15973,7 +16094,7 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; -+-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) +-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) -- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 467b7bf9a..475f7a44a 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` 仅在 `Asia/Shanghai` 每日 09:00、12:30、16:00 三个固定槽位自动查询;本轮结果无论成功、无变化或失败都进入下一固定槽位,不额外补跑或指数退避,实际 `EMPTY_RETURNED` 后停止;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `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 5e3a0ebe1..1a497bbc3 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 @@ -39,12 +39,8 @@ public class CarrierScheduleQueryScheduler { this.repository = repository; this.service = service; this.integration = integration; } - @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}") + @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}", + initialDelayString = "${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}") public void poll() { LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); int batch = Math.max(1, Math.min(batchSize, 100)); @@ -65,6 +61,7 @@ public class CarrierScheduleQueryScheduler { /** Executes only after this worker wins the database lease. */ void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) { if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return; + if (service.deferMissedNormalSlot(candidate.getId(), now)) return; try { var response = integration.query(internalToken, new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo())); 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 94983631d..336cc57e0 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 @@ -53,6 +53,7 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { + static final int MAX_SLOT_ATTEMPTS = 3; private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @@ -217,34 +218,76 @@ public class CarrierScheduleQueryService { return false; } + @Transactional + public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return false; + int attempts = value.getAttemptCount() == null ? 0 : value.getAttemptCount(); + boolean staleRetry = attempts > 0 && value.getWindowStartedAt() != null + && !scanAt.isBefore(CarrierScheduleQuerySlots.nextSlot(value.getWindowStartedAt())); + if (staleRetry) { + LocalDateTime currentSlot = CarrierScheduleQuerySlots.fixedSlotInCurrentMinute(scanAt); + if (currentSlot != null) { + value.setNextAllowedAt(currentSlot); value.setWindowStartedAt(currentSlot); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return false; + } + } else if (!CarrierScheduleQuerySlots.isMissedNormalSlot( + value.getNextAllowedAt(), value.getAttemptCount(), scanAt)) return false; + value.setLeaseOwner(null); value.setLeaseUntil(null); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(scanAt)); + value.setWindowStartedAt(value.getNextAllowedAt()); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return true; + } + @Transactional public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryEntity value = requiredLocked(id); - insertAttempt(value, outcome, errorCode, errorMessage); + int slotAttemptNo = Math.min(MAX_SLOT_ATTEMPTS, increment(value.getAttemptCount())); + insertAttempt(value, slotAttemptNo, outcome, errorCode, errorMessage); if ("STOPPED".equals(value.getStatus())) return; CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); - 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)); + LocalDateTime nextFixedSlot = CarrierScheduleQuerySlots.nextSlot(now); + if ("RETRYABLE_FAILURE".equals(outcome)) { + value.setStatus("READY"); + value.setConsecutiveFailures(slotAttemptNo); + LocalDateTime retryAt = now.plusMinutes(15L << Math.max(0, slotAttemptNo - 1)); + if (slotAttemptNo < MAX_SLOT_ATTEMPTS && retryAt.isBefore(nextFixedSlot)) { + if (slotAttemptNo == 1 || value.getWindowStartedAt() == null) { + value.setWindowStartedAt(value.getNextAllowedAt()); + } + value.setAttemptCount(slotAttemptNo); value.setNextAllowedAt(retryAt); + } else { + resetForNextFixedSlot(value, nextFixedSlot); + } + } else { + value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) + ? "MANUAL_REQUIRED" : "READY"); + resetForNextFixedSlot(value, nextFixedSlot); + } 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, "carrier-scheduler"); } - private void insertAttempt(CarrierScheduleQueryEntity value, String outcome, + private static void resetForNextFixedSlot(CarrierScheduleQueryEntity value, LocalDateTime nextFixedSlot) { + value.setConsecutiveFailures(0); value.setAttemptCount(0); + value.setWindowStartedAt(nextFixedSlot); value.setNextAllowedAt(nextFixedSlot); + } + + private void insertAttempt(CarrierScheduleQueryEntity value, int slotAttemptNo, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); - attempt.setWindowAttemptNo(increment(value.getAttemptCount())); + attempt.setWindowAttemptNo(slotAttemptNo); attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); boolean manual = "MANUAL_REQUIRED".equals(outcome); @@ -301,7 +344,8 @@ public class CarrierScheduleQueryService { value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); - value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); + value.setWindowStartedAt(value.getNextAllowedAt()); value.setAttemptCount(0); + value.setConsecutiveFailures(0); 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(); CarrierScheduleQueryEntity after = required(id); 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 index 9165c9714..cc0a4d4f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java @@ -17,4 +17,28 @@ public final class CarrierScheduleQuerySlots { if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON); return now.toLocalDate().plusDays(1).atTime(MORNING); } + + public static boolean isFixedSlot(LocalDateTime value) { + if (value == null || value.getSecond() != 0 || value.getNano() != 0) return false; + return value.toLocalTime().equals(MORNING) || value.toLocalTime().equals(NOON) + || value.toLocalTime().equals(AFTERNOON); + } + + public static boolean isWithinFixedSlotMinute(LocalDateTime value) { + if (value == null) return false; + LocalTime minute = value.toLocalTime().withSecond(0).withNano(0); + return minute.equals(MORNING) || minute.equals(NOON) || minute.equals(AFTERNOON); + } + + public static LocalDateTime fixedSlotInCurrentMinute(LocalDateTime value) { + return isWithinFixedSlotMinute(value) ? value.withSecond(0).withNano(0) : null; + } + + /** A normal slot is executable only during its scheduled minute; technical retry times are unaffected. */ + public static boolean isMissedNormalSlot(LocalDateTime nextAllowedAt, Integer attemptCount, + LocalDateTime scanAt) { + return (attemptCount == null || attemptCount == 0) && isFixedSlot(nextAllowedAt) + && !isWithinFixedSlotMinute(scanAt) + && !scanAt.isBefore(nextAllowedAt.plusMinutes(1)); + } } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 226cc98ae..ed6994938 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -35,9 +35,8 @@ baiyue: 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 * * *} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} 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} 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 7a9ec3fc5..f5bf83b25 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 '累计自动查询调用次数', - next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', + 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 '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -64,13 +64,14 @@ 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')) -) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; + '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='六家船公司固定槽位与技术退避查询任务'; 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 '任务累计自动查询调用序号', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -86,6 +87,7 @@ 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 2a8b46bed..5cf380ab9 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 and freshness fields; automatic execution uses the fixed Shanghai slots} + '200': {description: Query tasks with source and freshness fields; normal execution uses fixed Shanghai slots while retryable technical failures may retry within the current slot} 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 d707b84f7..4ee740534 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,10 +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("累计自动查询调用次数") - .contains("下一个09:00、12:30或16:00固定查询槽位") - .doesNotContain("attempt_count BETWEEN 0 AND 3") - .doesNotContain("window_attempt_no BETWEEN 1 AND 3") + .contains("当前正常查询槽位已调用次数;最多3次") + .contains("下一正常槽位或槽内技术退避时间") + .contains("attempt_count BETWEEN 0 AND 3") + .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") @@ -63,10 +63,24 @@ 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("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()") + .contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'") + .contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL") + .contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL") + .contains("ADD COLUMN stopped_at DATETIME(3) NULL") + .contains("ADD COLUMN stop_reason VARCHAR(500) NULL") + .contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN window_started_at DATETIME(3) NULL") + .contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") + .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") + .contains("SET @carrier_schedule_next = CASE") + .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") + .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") + .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") + .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") + .doesNotContain("\n+--") .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 fd95bc508..6c109ac37 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 @@ -27,15 +27,15 @@ import static org.mockito.Mockito.when; class CarrierScheduleQuerySchedulerTest { @Test - void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception { + void declaresOneConfigurableDueScannerRatherThanDirectCarrierCronCalls() 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}"); + assertThat(schedules).singleElement().satisfies(value -> { + assertThat(value.fixedDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}"); + assertThat(value.initialDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}"); + assertThat(value.cron()).isEmpty(); + }); } @Test 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 70d143cc3..554fa4435 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 @@ -231,10 +231,14 @@ class CarrierScheduleQueryServiceTest { 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).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(1).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(2).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 15)); + assertThat(tasks.get(4).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getAttemptCount()).isEqualTo(1); + assertThat(tasks).filteredOn(value -> value != tasks.get(3)) + .allSatisfy(value -> assertThat(value.getAttemptCount()).isZero()); assertThat(tasks.get(0).getStatus()).isEqualTo("READY"); assertThat(tasks.get(1).getStatus()).isEqualTo("READY"); assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED"); @@ -242,6 +246,96 @@ class CarrierScheduleQueryServiceTest { assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED"); } + @Test + void retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:00:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 15)); + assertThat(task.getAttemptCount()).isEqualTo(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:15:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 45)); + assertThat(task.getAttemptCount()).isEqualTo(2); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:45:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + + ArgumentCaptor attempts = + ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); + verify(repository, org.mockito.Mockito.times(3)).insert(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(CarrierScheduleQueryAttemptEntity::getWindowAttemptNo) + .containsExactly(1, 2, 3); + } + + @Test + void retryThatWouldReachTheNextSlotWaitsForTheSlotInstead() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 12, 20)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + set("clock", Clock.fixed(Instant.parse("2026-08-10T04:20:00Z"), ZoneOffset.UTC)); + + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + } + + @Test + void missedNormalSlotIsDeferredWithoutCallingTheProviderOrWritingAnAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + task.setLeaseOwner("worker"); task.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 10, 5)); + when(repository.lock(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + boolean deferred = service.deferMissedNormalSlot(1L, LocalDateTime.of(2026, 8, 10, 10, 0)); + + assertThat(deferred).isTrue(); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getLeaseOwner()).isNull(); assertThat(task.getLeaseUntil()).isNull(); + verify(repository, never()).insert(any(CarrierScheduleQueryAttemptEntity.class)); + } + + @Test + void staleRetryBecomesANewFirstAttemptOnlyDuringTheNextFixedSlotMinute() { + CarrierScheduleQueryEntity atSlot = task(2, 0); + atSlot.setStatus("READY"); atSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + atSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); atSlot.setConsecutiveFailures(2); + atSlot.setLeaseOwner("worker"); atSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 12, 35)); + when(repository.lock(1L)).thenReturn(atSlot); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + boolean executeCurrentSlot = service.deferMissedNormalSlot(1L, + LocalDateTime.of(2026, 8, 10, 12, 30, 20)); + + assertThat(executeCurrentSlot).isFalse(); + assertThat(atSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(atSlot.getAttemptCount()).isZero(); + assertThat(atSlot.getLeaseOwner()).isEqualTo("worker"); + + CarrierScheduleQueryEntity outsideSlot = task(2, 0); + outsideSlot.setStatus("READY"); outsideSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + outsideSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); + outsideSlot.setLeaseOwner("worker"); outsideSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 13, 5)); + when(repository.lock(2L)).thenReturn(outsideSlot); + + boolean deferred = service.deferMissedNormalSlot(2L, LocalDateTime.of(2026, 8, 10, 13, 0)); + + assertThat(deferred).isTrue(); + assertThat(outsideSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 16, 0)); + assertThat(outsideSlot.getAttemptCount()).isZero(); + assertThat(outsideSlot.getLeaseOwner()).isNull(); + } + @Test void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() { CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry(); 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 index 31e276927..0292a766a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java @@ -16,6 +16,10 @@ class CarrierScheduleQuerySlotsTest { 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)); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 0, 59))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 1, 0))).isTrue(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(12, 30, 30))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 15, 0), 1, at(10, 0, 0))).isFalse(); } private static LocalDateTime at(int hour, int minute, int second) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 5e0c86875..b29dba168 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -84,6 +84,8 @@ class Phase3FinalUnifiedSqlContractTest { .contains("platform:firstleg:outbound-dispatch:%") .contains("platform:firstleg:customs:%") .contains("platform:firstleg:port-schedule:%") + .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") + .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36") .contains("platform:firstleg:pda:%") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); @@ -210,6 +212,10 @@ class Phase3FinalUnifiedSqlContractTest { assertThat(sql) .doesNotContain("\n ADD COLUMN IF NOT EXISTS") .doesNotContain("\n DROP CHECK IF EXISTS") + .doesNotContain("\n+--") + .doesNotContain("\n---") + .doesNotContain("\n>>>") + .doesNotContain("\n<<<") .contains("CREATE PROCEDURE phase3_pda_add_column") .contains("CREATE PROCEDURE phase3_pda_replace_operation_check"); } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java index ebcca60ee..f92681d06 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; @@ -32,7 +33,10 @@ import java.util.regex.Pattern; public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML"; private static final String LOADED = "LOADED (FCL) ON VESSEL"; - private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM-dd-uuuu", Locale.ENGLISH); + private static final DateTimeFormatter DAY = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("MMM-dd-uuuu") + .toFormatter(Locale.ENGLISH); private static final Pattern TABLE = Pattern.compile("(?is)]*>(.*?)"); private static final Pattern ROW = Pattern.compile("(?is)]*>(.*?)"); private static final Pattern CELL = Pattern.compile("(?is)]*>(.*?)");