This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
+8
View File
@@ -8,6 +8,14 @@ baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue:
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
sml-automated: ${SML_TRACKING_ENABLED:false}
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
lastleg:
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}
outbox-relay:
+36
View File
@@ -21,3 +21,39 @@ baiyue:
max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+6
View File
@@ -6,6 +6,12 @@ spring.datasource:
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
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}
firstleg:
inbound:
allow-rejected-measurement: false
@@ -15,3 +15,39 @@ baiyue:
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+36
View File
@@ -93,6 +93,12 @@ services:
FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME}
FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD}
LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}
MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false}
SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false}
ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false}
COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false}
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
finance:
<<: *common
@@ -187,6 +193,36 @@ services:
AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-}
AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-}
AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-}
MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false}
MATSON_TRACKING_PAGE_URL: ${MATSON_TRACKING_PAGE_URL:-https://www.matson.com/shipment-tracking.html}
MATSON_TRACKING_QUERY_URL: ${MATSON_TRACKING_QUERY_URL:-https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
MATSON_TRACKING_CONNECT_TIMEOUT: ${MATSON_TRACKING_CONNECT_TIMEOUT:-10s}
MATSON_TRACKING_REQUEST_TIMEOUT: ${MATSON_TRACKING_REQUEST_TIMEOUT:-30s}
MATSON_TRACKING_MIN_REQUEST_INTERVAL: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:-5s}
SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false}
SML_TRACKING_QUERY_URL: ${SML_TRACKING_QUERY_URL:-https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
SML_TRACKING_CONNECT_TIMEOUT: ${SML_TRACKING_CONNECT_TIMEOUT:-10s}
SML_TRACKING_REQUEST_TIMEOUT: ${SML_TRACKING_REQUEST_TIMEOUT:-30s}
SML_TRACKING_MIN_REQUEST_INTERVAL: ${SML_TRACKING_MIN_REQUEST_INTERVAL:-5s}
ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false}
ZIM_AUTHORIZATION_URL: ${ZIM_AUTHORIZATION_URL:-https://apigw.zim.com/authorize/v1}
ZIM_TRACING_BASE_URL: ${ZIM_TRACING_BASE_URL:-https://apigw.zim.com/tracing/v2}
ZIM_CLIENT_ID: ${ZIM_CLIENT_ID:-}
ZIM_CLIENT_SECRET: ${ZIM_CLIENT_SECRET:-}
ZIM_TRACKING_SUBSCRIPTION_KEY: ${ZIM_TRACKING_SUBSCRIPTION_KEY:-}
ZIM_TRACKING_CONNECT_TIMEOUT: ${ZIM_TRACKING_CONNECT_TIMEOUT:-10s}
ZIM_TRACKING_REQUEST_TIMEOUT: ${ZIM_TRACKING_REQUEST_TIMEOUT:-30s}
ZIM_TRACKING_MAX_CONCURRENCY: ${ZIM_TRACKING_MAX_CONCURRENCY:-10}
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
EMC_TRACKING_QUERY_URL: ${EMC_TRACKING_QUERY_URL:-https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
EMC_TRACKING_CONNECT_TIMEOUT: ${EMC_TRACKING_CONNECT_TIMEOUT:-10s}
EMC_TRACKING_REQUEST_TIMEOUT: ${EMC_TRACKING_REQUEST_TIMEOUT:-30s}
EMC_TRACKING_MIN_REQUEST_INTERVAL: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:-5s}
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
HEDE_TRACKING_QUERY_URL: ${HEDE_TRACKING_QUERY_URL:-http://elines.hedehk.com/getVBilldynamic}
HEDE_TRACKING_CONNECT_TIMEOUT: ${HEDE_TRACKING_CONNECT_TIMEOUT:-10s}
HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s}
HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s}
event-bridge-service:
<<: *common
@@ -10244,11 +10244,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API',
status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间',
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
@@ -10290,12 +10290,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
('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='六家船公司官网查询任务与三天窗口频控';
@@ -10306,7 +10307,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败',
attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果',
coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是',
coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码',
manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因',
@@ -10320,13 +10321,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
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 ('MANUAL_REQUIRED','SUCCESS','FAILED'))
(attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED'))
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
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',
action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果',
action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作',
from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本',
reason VARCHAR(1000) NULL COMMENT '操作原因或说明',
@@ -10339,7 +10340,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED',
'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'))
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
@@ -10347,6 +10349,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
@@ -10363,10 +10366,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
(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_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100))
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态',
exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码',
active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除',
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
location_code VARCHAR(32) NULL COMMENT '地点编码',
location_name VARCHAR(128) NULL COMMENT '地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key),
KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_exception_code CHECK
(exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')),
CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1))
) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
@@ -10409,6 +10437,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
@@ -10422,6 +10452,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
@@ -10485,7 +10519,7 @@ BEGIN
WHERE table_schema='baiyue_firstleg' AND table_name IN
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
'fl_carrier_schedule_node_fact')) <> 5 THEN
'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
@@ -11899,6 +11933,7 @@ 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', '船司查询写操作幂等与请求摘要');
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实');
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实');
CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表');
CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划');
CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联');
@@ -12479,7 +12514,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_at'
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by', '最后修改人ID');
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公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面');
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', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空');
@@ -12503,10 +12538,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务操作状态:READY、MANUAL_REQUIRED、STOPPED');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近实际标准船节点;预计事实不推进');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近实际标准节点发生时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者');
@@ -12520,7 +12555,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updat
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_attempt', 'access_mode', '本次尝试的官网访问方式快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败');
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', '本次尝试的船公司编码快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap', '是否存在自动化覆盖缺口:0否、1是');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap_code', '自动化覆盖缺口编码');
@@ -12534,7 +12569,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt'
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_history', 'action', '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果');
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', '变更前任务数据快照;创建时为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'created_at', '历史记录创建时间');
@@ -12559,6 +12594,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'i
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号');
@@ -12570,6 +12606,20 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'l
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'id', '船节点异常事实ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'query_id', '所属查询任务ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'event_key', '供应商异常事件幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_code', '供应商编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_event_id', '供应商稳定事件ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_status', '供应商原始异常状态');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'exception_code', '平台异常编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'active', '1异常发生;0异常解除');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'observed_at', '供应商观测时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_code', '地点编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_name', '地点名称');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'source_reference', '可审计来源引用');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'raw_payload_hash', '原始响应SHA-256');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'created_at', '事实入库时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致');
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型');
+2 -2
View File
@@ -31,7 +31,7 @@
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
@@ -51,7 +51,7 @@
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 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` 失败关闭复核。
@@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient {
}
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
record QueryResult(String providerCode, String outcome, List<Observation> observations,
List<ExceptionObservation> exceptions,
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
record Observation(String providerEventId, String providerNodeCode, String standardNode,
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode,
boolean active, LocalDateTime observedAt, String locationCode,
String locationName, String sourceReference, String rawPayloadHash) { }
}
@@ -1,48 +0,0 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries")
public class InternalCarrierScheduleQueryController {
private final CarrierScheduleQueryService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; }
@PostMapping("/{queryId}/stop")
public ApiResponse<Detail> stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable @Positive long queryId,
@Valid @RequestBody StopRequest request) {
authenticate(token);
return ApiResponse.success(service.stop(queryId, request.reason()));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
public record StopRequest(@NotBlank @Size(max = 500) String reason) { }
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
@@ -93,4 +94,16 @@ public class PlatformCarrierScheduleQueryController {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request));
}
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long queryId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ManualNodeRequest request) {
FirstlegScopeGuard.platform(user, service.customerId(queryId));
return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.portschedule.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
public enum CarrierScheduleExceptionCode {
CUSTOMS_HOLD,
TERMINAL_HOLD,
DOCUMENT_HOLD,
UNKNOWN_HOLD;
public static CarrierScheduleExceptionCode require(String value) {
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value);
}
}
}
@@ -9,10 +9,14 @@ import java.util.Locale;
public enum CarrierScheduleNode {
DEPARTED_WAREHOUSE(10),
AT_ORIGIN_PORT(20),
DEPARTED_ORIGIN_PORT(30),
IN_TRANSIT(40),
ARRIVED(50),
PICKUP_READY(60);
LOADED_ON_VESSEL(30),
DEPARTED_ORIGIN_PORT(40),
IN_TRANSIT(50),
ARRIVED_DESTINATION_PORT(60),
DISCHARGED_FROM_VESSEL(70),
PICKUP_READY(80),
PICKED_UP(90),
EMPTY_RETURNED(100);
private final int sequence;
@@ -16,9 +16,9 @@ public final class CarrierScheduleQueryDtos {
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, @Size(max = 16) String carrierCode,
@Size(max = 24) String status, Boolean expired,
@Size(max = 128) String keyword, @Min(1) long page,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
@Size(max = 128) String keyword, @Min(1) Long page,
@Min(1) @Max(200) Long size) {
public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; }
}
public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion,
@@ -34,6 +34,15 @@ public final class CarrierScheduleQueryDtos {
@NotNull @Positive Long portScheduleId,
@NotBlank @Size(max = 1000) String reason) {}
public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 32) String standardNode,
@NotNull LocalDateTime observedAt,
LocalDateTime estimatedAt,
LocalDateTime actualAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@NotBlank @Size(max = 1000) String reason) {}
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 64) String providerNodeCode,
@@ -46,6 +55,17 @@ public final class CarrierScheduleQueryDtos {
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode,
@NotBlank @Size(max = 128) String providerEventId,
@NotBlank @Size(max = 128) String providerStatus,
@NotBlank @Size(max = 32) String exceptionCode,
boolean active,
@NotNull LocalDateTime observedAt,
@Size(max = 32) String locationCode,
@Size(max = 128) String locationName,
@Size(max = 500) String sourceReference,
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
String accessMode, boolean automated, boolean coverageGap,
String coverageGapCode, String manualReason) {}
@@ -53,21 +73,28 @@ public final class CarrierScheduleQueryDtos {
public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId,
Long containerBookingId, String carrierCode, String trackingType, String trackingNo,
String officialUrl, String accessMode, String status, String triggerSource,
String latestStandardNode, LocalDateTime latestNodeObservedAt,
String latestActualNode, LocalDateTime latestActualAt,
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
Long resultPortScheduleId, String manualReason, String lastErrorCode,
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
public record QueryView(Task query, boolean expired) {}
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
public record NodeFact(Long id, String providerCode, String providerEventId,
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record ExceptionFact(Long id, String providerCode, String providerEventId,
String providerStatus, String exceptionCode, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
/** Frontend detail intentionally excludes scheduler leases and raw query attempts. */
public record Detail(QueryView query, List<NodeFact> nodes, List<History> histories) {}
public record Detail(QueryView query, List<NodeFact> nodes, List<ExceptionFact> exceptions,
List<History> histories) {}
}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_carrier_schedule_exception_fact")
public class CarrierScheduleExceptionFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerStatus;
private String exceptionCode;
private Boolean active;
private LocalDateTime observedAt;
private String locationCode;
private String locationName;
private String sourceReference;
private String rawPayloadHash;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -18,6 +18,7 @@ public class CarrierScheduleNodeFactEntity {
private Long queryId;
private String eventKey;
private String providerCode;
private String providerEventId;
private String providerNodeCode;
private String standardNode;
private Integer nodeSequence;
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CarrierScheduleExceptionFactMapper extends BaseMapper<CarrierScheduleExceptionFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_exception_fact
(id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active,
observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},
#{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE id=id
""")
int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value);
}
@@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param;
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
@Insert("""
INSERT INTO fl_carrier_schedule_node_fact
(id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at,
(id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at,
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
VALUES
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode},
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode},
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
CURRENT_TIMESTAMP(3))
@@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
WHERE status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
ORDER BY next_allowed_at,id LIMIT #{limit}
@@ -58,7 +58,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Update("""
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
""")
@@ -10,11 +10,13 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleExceptionFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -28,6 +30,7 @@ public class CarrierScheduleQueryRepository {
@Resource private CarrierScheduleQueryHistoryMapper histories;
@Resource private CarrierScheduleQueryOperationMapper operations;
@Resource private CarrierScheduleNodeFactMapper nodes;
@Resource private CarrierScheduleExceptionFactMapper exceptions;
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
@@ -67,6 +70,15 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
.orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt)
.orderByAsc(CarrierScheduleExceptionFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
return exceptions.insertIfAbsent(value) == 1;
}
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -16,15 +17,27 @@ import java.util.Map;
@Component
public class CarrierScheduleAdapterRegistry {
private static final Map<String, Definition> DEFINITIONS = definitions();
@Value("${baiyue.carrier-schedule.providers.matson-automated:false}")
private boolean matsonAutomated;
@Value("${baiyue.carrier-schedule.providers.hede-automated:false}")
private boolean hedeAutomated;
@Value("${baiyue.carrier-schedule.providers.sml-automated:false}")
private boolean smlAutomated;
@Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}")
private boolean zimOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}")
private boolean coscoOfficialApiEnabled;
@Value("${baiyue.carrier-schedule.providers.emc-automated:false}")
private boolean emcAutomated;
public List<CarrierDescriptor> descriptors() {
return DEFINITIONS.values().stream().map(Definition::descriptor).toList();
return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList();
}
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
Definition definition = required(carrierCode);
return new PreparedAttempt(definition.code(), definition.accessMode(),
officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED",
officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED",
definition.coverageGapCode(), definition.manualReason());
}
@@ -52,9 +65,6 @@ public class CarrierScheduleAdapterRegistry {
private static String officialUrl(Definition definition, String trackingType, String trackingNo) {
String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8);
if ("ZIM".equals(definition.code())) {
return definition.officialUrl() + "?consnumber=" + encoded;
}
if ("EMC".equals(definition.code())) {
String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl";
return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN";
@@ -65,26 +75,40 @@ public class CarrierScheduleAdapterRegistry {
private static Map<String, Definition> definitions() {
Map<String, Definition> values = new LinkedHashMap<>();
add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果"));
"PUBLIC_WEB_PROXY", null, null));
add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment",
"HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/",
"DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询"));
"PUBLIC_FORM", null, null));
add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询"));
add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/",
"OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED",
"COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询"));
add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入"));
add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic",
"PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果"));
"PUBLIC_HTML", null, null));
return Collections.unmodifiableMap(values);
}
private static void add(Map<String, Definition> values, Definition value) { values.put(value.code(), value); }
private boolean automated(Definition definition) {
return switch (definition.code()) {
case "MATSON" -> matsonAutomated;
case "SML" -> smlAutomated;
case "ZIM" -> zimOfficialApiEnabled;
case "COSCO" -> coscoOfficialApiEnabled;
case "EMC" -> emcAutomated;
case "HEDE" -> hedeAutomated;
default -> false;
};
}
private record Definition(String code, String name, String officialUrl, String accessMode,
String coverageGapCode, String manualReason) {
CarrierDescriptor descriptor() {
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
CarrierDescriptor descriptor(boolean automated) {
return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated,
coverageGapCode, manualReason);
}
boolean matches(String value) {
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import org.springframework.beans.factory.annotation.Value;
@@ -61,6 +62,14 @@ public class CarrierScheduleQueryScheduler {
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
? List.of() : result.exceptions();
for (var exception : exceptions) {
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
} catch (RuntimeException exception) {
@@ -9,17 +9,23 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ExceptionFact;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
@@ -128,7 +134,6 @@ public class CarrierScheduleQueryService {
@Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
@@ -136,40 +141,83 @@ public class CarrierScheduleQueryService {
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if (current == null || node.sequence() >= current.sequence()) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt());
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt());
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED"
: node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY");
value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
}
CarrierScheduleQueryEntity after = required(id);
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
0L, "carrier-scheduler");
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
after, 0L, "carrier-scheduler");
return detail(after);
}
@Transactional
public Detail stop(long id, String reason) {
public Detail applyProviderException(long id, ProviderExceptionRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id);
if ("STOPPED".equals(value.getStatus())) return detail(value);
CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode());
if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code,
request.active(), request.observedAt(), request.locationCode(), request.locationName(),
request.sourceReference(), request.rawPayloadHash())) {
return detail(value);
}
addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value,
0L, "carrier-scheduler");
return detail(value);
}
@Transactional
public Detail addManualNode(long id, long operatorId, String operatorName, String key,
ManualNodeRequest request) {
String operation = "MANUAL_NODE";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
if (request.estimatedAt() == null && request.actualAt() == null) {
throw badRequest("estimatedAt和actualAt至少提供一个");
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason));
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
CarrierScheduleQueryEntity after = required(id);
addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L,
"carrier-scheduler");
return detail(after);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request))) {
return complete(id, operatorId, key, detail(value));
}
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
operatorId, operatorName);
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt, LocalDateTime observedAt,
long operatorId, String operatorName) {
if (actualAt == null || "STOPPED".equals(value.getStatus())) return false;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
boolean advances = current == null || node.sequence() > current.sequence()
|| node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null
|| actualAt.isAfter(value.getLatestNodeObservedAt()));
if (advances) {
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt);
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt);
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
if (node == CarrierScheduleNode.EMPTY_RETURNED) {
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));
}
if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent();
return true;
}
return false;
}
@Transactional
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
CarrierScheduleQueryEntity value = requiredLocked(id);
insertAttempt(value, outcome, errorCode, errorMessage);
if ("STOPPED".equals(value.getStatus())) return;
CarrierScheduleQueryEntity before = copy(value);
LocalDateTime now = now();
@@ -177,6 +225,7 @@ public class CarrierScheduleQueryService {
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 {
@@ -189,9 +238,7 @@ public class CarrierScheduleQueryService {
value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts));
value.setConsecutiveFailures(failures);
if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) {
if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) {
value.setStatus("MANUAL_REQUIRED");
}
value.setStatus("MANUAL_REQUIRED");
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
} else {
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
@@ -208,6 +255,20 @@ public class CarrierScheduleQueryService {
"carrier-scheduler");
}
private void insertAttempt(CarrierScheduleQueryEntity value, String outcome,
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.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode());
attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome);
boolean manual = "MANUAL_REQUIRED".equals(outcome);
attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null);
attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode));
attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L);
attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt);
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
@@ -309,16 +370,24 @@ public class CarrierScheduleQueryService {
private Detail detail(CarrierScheduleQueryEntity value) {
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
repository.exceptions(value.getId()).stream().map(this::exception).toList(),
repository.histories(value.getId()).stream().map(this::history).toList());
}
private NodeFact node(CarrierScheduleNodeFactEntity value) {
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(),
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) {
return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(),
value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()),
value.getObservedAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
value.getRawPayloadHash(), value.getCreatedAt());
}
private History history(CarrierScheduleQueryHistoryEntity value) {
return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(),
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
@@ -332,6 +401,7 @@ public class CarrierScheduleQueryService {
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim());
fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name());
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
@@ -339,6 +409,21 @@ public class CarrierScheduleQueryService {
return repository.insertIfAbsent(fact);
}
private boolean addException(long queryId, String providerCode, String providerEventId,
String providerStatus, CarrierScheduleExceptionCode code, boolean active,
LocalDateTime observedAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity();
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim());
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|| !value.getExpiresAt().isAfter(now);
@@ -25,6 +25,14 @@ spring:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
sml-automated: ${SML_TRACKING_ENABLED:false}
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
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}
@@ -12,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API',
status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进',
latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间',
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
@@ -58,12 +58,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK
(latest_standard_node IS NULL OR latest_standard_node IN
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
('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='六家船公司官网查询任务与三天窗口频控';
@@ -74,7 +75,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败',
attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果',
coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是',
coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码',
manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因',
@@ -88,13 +89,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
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 ('MANUAL_REQUIRED','SUCCESS','FAILED'))
(attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED'))
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
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',
action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果',
action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作',
from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本',
reason VARCHAR(1000) NULL COMMENT '操作原因或说明',
@@ -107,7 +108,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED',
'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'))
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
@@ -115,6 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
@@ -131,10 +134,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_node_standard CHECK
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
(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_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100))
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码',
provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID',
provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态',
exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码',
active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除',
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
location_code VARCHAR(32) NULL COMMENT '地点编码',
location_name VARCHAR(128) NULL COMMENT '地点名称',
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key),
KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id),
CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
CONSTRAINT chk_carrier_schedule_exception_code CHECK
(exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')),
CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1))
) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
@@ -177,6 +205,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update;
DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
@@ -190,6 +220,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$
CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
@@ -253,7 +287,7 @@ BEGIN
WHERE table_schema='baiyue_firstleg' AND table_name IN
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
'fl_carrier_schedule_node_fact')) <> 5 THEN
'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
@@ -1,23 +0,0 @@
openapi: 3.0.3
info:
title: Carrier Schedule Internal Handoff API
version: 1.0.0
description: Internal-only handoff contract; it is not a platform or merchant frontend API.
paths:
/internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop:
post:
summary: Stop tracking after the overseas pickup handoff has completed
parameters:
- {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 500}
responses:
'200': {description: Idempotent STOPPED task detail}
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Carrier Schedule Query API
version: 1.0.0
description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness.
description: Phase-4 carrier tracking from domestic outbound confirmation through actual empty return.
paths:
/api/platform/v1/carrier-schedule-queries/carriers:
get:
@@ -43,18 +43,21 @@ paths:
summary: Get task, standardized node facts and immutable business history
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: Stable task detail}
'200':
description: Stable task detail
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/history:
parameters: [{$ref: '#/components/parameters/QueryId'}]
get:
summary: Get immutable task history
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: CREATE, ATTEMPT and MANUAL_RESULT history}
'200': {description: Immutable business history; scheduler attempts are not exposed}
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Link a validated manual port-schedule observation without pretending automated success
description: This legacy port-schedule association does not append a standard node and never stops tracking; phase-4 manual facts use /manual-nodes.
x-permission: platform:firstleg:port-schedule-query:manual
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
@@ -63,7 +66,24 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ManualResultRequest'}
responses:
'200': {description: Successful manual fallback and next query time}
'200':
description: Successful manual fallback and next query time
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:
parameters: [{$ref: '#/components/parameters/QueryId'}]
post:
summary: Append one immutable manual estimated or actual standard-node fact
x-permission: platform:firstleg:port-schedule-query:manual
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
responses:
'200':
description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
components:
parameters:
QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -71,8 +91,11 @@ components:
schemas:
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]}
StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode:
type: string
enum: [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]
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
CreateRequest:
type: object
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
@@ -92,9 +115,112 @@ components:
expectedVersion: {type: integer, minimum: 0}
portScheduleId: {type: integer, format: int64, minimum: 1}
reason: {type: string, minLength: 1, maxLength: 1000}
ManualNodeRequest:
type: object
required: [expectedVersion, standardNode, observedAt, reason]
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node.
properties:
expectedVersion: {type: integer, minimum: 0}
standardNode: {$ref: '#/components/schemas/StandardNode'}
observedAt: {type: string, format: date-time}
estimatedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
reason: {type: string, minLength: 1, maxLength: 1000}
Task:
type: object
required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version]
properties:
id: {type: integer, format: int64}
queryNo: {type: string}
customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string}
officialUrl: {type: string, nullable: true}
accessMode: {type: string, nullable: true}
status: {$ref: '#/components/schemas/QueryStatus'}
triggerSource: {type: string}
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
latestActualAt: {type: string, format: date-time, nullable: true}
lastSuccessAt: {type: string, format: date-time, nullable: true}
sourceUpdatedAt: {type: string, format: date-time, nullable: true}
expiresAt: {type: string, format: date-time, nullable: true}
resultPortScheduleId: {type: integer, format: int64, nullable: true}
manualReason: {type: string, nullable: true}
lastErrorCode: {type: string, nullable: true}
lastErrorMessage: {type: string, nullable: true}
stoppedAt: {type: string, format: date-time, nullable: true}
stopReason: {type: string, nullable: true}
version: {type: integer, minimum: 0}
QueryView:
type: object
required: [query, expired]
properties:
query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed}
query: {$ref: '#/components/schemas/Task'}
expired: {type: boolean}
NodeFact:
type: object
required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt]
properties:
id: {type: integer, format: int64}
providerCode: {type: string, maxLength: 32}
providerEventId: {type: string, maxLength: 128}
providerNodeCode: {type: string, maxLength: 64}
standardNode: {$ref: '#/components/schemas/StandardNode'}
nodeSequence: {type: integer, enum: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
observedAt: {type: string, format: date-time}
estimatedAt: {type: string, format: date-time, nullable: true}
actualAt: {type: string, format: date-time, nullable: true}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time}
ExceptionFact:
type: object
required: [id, providerCode, providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash, createdAt]
properties:
id: {type: integer, format: int64}
providerCode: {type: string, maxLength: 32}
providerEventId: {type: string, maxLength: 128}
providerStatus: {type: string, maxLength: 128}
exceptionCode: {$ref: '#/components/schemas/ExceptionCode'}
active: {type: boolean, description: true means hold occurred; false means released}
observedAt: {type: string, format: date-time}
locationCode: {type: string, maxLength: 32, nullable: true}
locationName: {type: string, maxLength: 128, nullable: true}
sourceReference: {type: string, maxLength: 500, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
createdAt: {type: string, format: date-time}
History:
type: object
required: [id, action, createdAt]
properties:
id: {type: integer, format: int64}
action: {type: string, enum: [CREATE, AUTO_CREATE, MANUAL_RESULT, MANUAL_NODE, NODE_OBSERVED, EXCEPTION_OBSERVED, PROVIDER_QUERY]}
fromVersion: {type: integer, nullable: true}
toVersion: {type: integer, nullable: true}
reason: {type: string, nullable: true}
operatorId: {type: integer, format: int64, nullable: true}
operatorName: {type: string, nullable: true}
createdAt: {type: string, format: date-time}
Detail:
type: object
required: [query, nodes, exceptions, histories]
description: Scheduler lease, attempt and internal execution fields are intentionally absent.
properties:
query: {$ref: '#/components/schemas/QueryView'}
nodes: {type: array, items: {$ref: '#/components/schemas/NodeFact'}}
exceptions: {type: array, items: {$ref: '#/components/schemas/ExceptionFact'}}
histories: {type: array, items: {$ref: '#/components/schemas/History'}}
DetailEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/Detail'}
@@ -15,10 +15,17 @@ class CarrierScheduleQueryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/carrier-schedule-queries:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY")
.contains("MANUAL_REQUIRED")
.contains("scheduler leases and raw attempts are never exposed")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED")
.contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD")
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
.contains("latestActualNode:")
.contains("latestActualAt:")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt");
}
@@ -22,13 +22,14 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create",
"manualResult", "platform:firstleg:port-schedule-query:manual");
"manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("manualResult")) {
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.portschedule.dto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryDtosTest {
@Test
void omittedPageAndSizeBindToDocumentedDefaults() {
var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null);
assertThat(query.page()).isEqualTo(1L);
assertThat(query.size()).isEqualTo(20L);
}
}
@@ -18,15 +18,25 @@ class CarrierScheduleQuerySqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
.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("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除")
.contains("标准船节点事实不可修改")
.contains("ARRIVED','PICKUP_READY','STOPPED")
.contains("船节点异常事实不可修改")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("LOADED_ON_VESSEL")
.contains("ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL")
.contains("PICKED_UP','EMPTY_RETURNED")
.contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD")
.contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("lease_owner")
.contains("consecutive_failures");
.contains("consecutive_failures")
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
}
@Test
@@ -36,7 +46,8 @@ class CarrierScheduleQuerySqlContractTest {
.contains("allocation.container_booking_id=NEW.container_booking_id")
.contains("id IN (5102184,5102186,5102188)")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("'platform:firstleg:port-schedule-query:attempt'")
.contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@@ -50,7 +61,9 @@ class CarrierScheduleQuerySqlContractTest {
.contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
}
private static String moduleSql() throws Exception {
@@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest {
}
@Test
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() {
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/");
assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/");
}
@Test
@@ -26,16 +26,20 @@ class CarrierScheduleQuerySchedulerTest {
task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567");
LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED",
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL",
now, null, now, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD",
"CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation),
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception),
null, null, now)));
scheduler.execute(task, now);
verify(service).applyProviderObservation(anyLong(), any());
verify(service).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
}
@@ -5,8 +5,12 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -15,7 +19,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
@@ -28,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,6 +54,7 @@ class CarrierScheduleQueryServiceTest {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.nodes(anyLong())).thenReturn(List.of());
when(repository.exceptions(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@@ -86,7 +94,7 @@ class CarrierScheduleQueryServiceTest {
}
@Test
void providerObservationAdvancesToArrivedWithoutExposingAttempts() {
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
@@ -96,14 +104,172 @@ class CarrierScheduleQueryServiceTest {
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1",
"DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(result.query().query().status()).isEqualTo("ARRIVED");
assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED");
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty();
}
@Test
void estimatedObservationIsSavedWithoutAdvancingActualNode() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL",
"ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void lateActualFactIsSavedButCannotRegressLatestNode() {
CarrierScheduleQueryEntity task = task(0, 5);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED",
"DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void onlyActualEmptyReturnedStopsTask() {
CarrierScheduleQueryEntity task = task(0, 2);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED",
"EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"));
assertThat(task.getStatus()).isEqualTo("STOPPED");
assertThat(task.getStopReason()).isEqualTo("已还柜");
assertThat(task.getNextAllowedAt()).isNull();
}
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1",
new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜"));
assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1",
"CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX",
"Long Beach", "MATSON:9974749",
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"));
ArgumentCaptor<CarrierScheduleExceptionFactEntity> fact =
ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class);
verify(repository).insertIfAbsent(fact.capture());
assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD");
assertThat(fact.getValue().getActive()).isTrue();
assertThat(task.getLatestStandardNode()).isNull();
}
@Test
void everySchedulerCompletionWritesImmutableAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.completeScheduledAttempt(1L, "NO_CHANGE", null, null);
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE");
assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler");
assertThat(task.getStatus()).isEqualTo("READY");
}
@Test
void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() {
CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isFalse();
assertThat(value.coverageGap()).isTrue();
});
ReflectionTestUtils.setField(registry, "matsonAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY");
});
ReflectionTestUtils.setField(registry, "hedeAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML");
});
ReflectionTestUtils.setField(registry, "smlAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true);
assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("OFFICIAL_API");
});
ReflectionTestUtils.setField(registry, "emcAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
}
@Test
void manualResultMustBelongToSameCustomerBookingAndContainer() {
CarrierScheduleQueryEntity task = task(1, 1);
@@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
+1 -1
View File
@@ -11,7 +11,7 @@
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md`
- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/四期/船节点标准化契约.md`
- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml`
- 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test`
@@ -8,7 +8,8 @@ import java.nio.file.Path;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.integration")
public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) {
public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi,
CarrierSchedule carrierSchedule) {
public record BaiduOcr(boolean enabled,
String apiKey,
String secretKey,
@@ -84,6 +85,136 @@ public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi)
}
}
public record CarrierSchedule(Matson matson, Hede hede, Sml sml, Zim zim, Evergreen evergreen) {
public record Matson(boolean enabled,
URI pageUrl,
URI queryUrl,
Duration connectTimeout,
Duration requestTimeout,
Duration minRequestInterval) {
public boolean configured() {
return enabled && pageUrl != null && queryUrl != null;
}
public Duration effectiveConnectTimeout() {
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
}
public Duration effectiveRequestTimeout() {
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
}
public Duration effectiveMinRequestInterval() {
return minRequestInterval != null && !minRequestInterval.isNegative()
? minRequestInterval : Duration.ofSeconds(5);
}
private static boolean positive(Duration value) {
return value != null && !value.isNegative() && !value.isZero();
}
}
public record Hede(boolean enabled,
URI queryUrl,
Duration connectTimeout,
Duration requestTimeout,
Duration minRequestInterval) {
public boolean configured() { return enabled && queryUrl != null; }
public Duration effectiveConnectTimeout() {
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
}
public Duration effectiveRequestTimeout() {
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
}
public Duration effectiveMinRequestInterval() {
return minRequestInterval != null && !minRequestInterval.isNegative()
? minRequestInterval : Duration.ofSeconds(5);
}
private static boolean positive(Duration value) {
return value != null && !value.isNegative() && !value.isZero();
}
}
public record Sml(boolean enabled,
URI queryUrl,
Duration connectTimeout,
Duration requestTimeout,
Duration minRequestInterval) {
public boolean configured() { return enabled && queryUrl != null; }
public Duration effectiveConnectTimeout() {
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
}
public Duration effectiveRequestTimeout() {
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
}
public Duration effectiveMinRequestInterval() {
return minRequestInterval != null && !minRequestInterval.isNegative()
? minRequestInterval : Duration.ofSeconds(5);
}
private static boolean positive(Duration value) {
return value != null && !value.isNegative() && !value.isZero();
}
}
public record Zim(boolean enabled,
URI authorizationUrl,
URI tracingBaseUrl,
String clientId,
String clientSecret,
String subscriptionKey,
Duration connectTimeout,
Duration requestTimeout,
int maxConcurrency) {
public boolean configured() {
return enabled && authorizationUrl != null && tracingBaseUrl != null
&& hasText(clientId) && hasText(clientSecret) && hasText(subscriptionKey);
}
public Duration effectiveConnectTimeout() {
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
}
public Duration effectiveRequestTimeout() {
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
}
public int effectiveMaxConcurrency() { return Math.max(1, Math.min(10, maxConcurrency)); }
private static boolean positive(Duration value) {
return value != null && !value.isNegative() && !value.isZero();
}
}
public record Evergreen(boolean enabled,
URI queryUrl,
Duration connectTimeout,
Duration requestTimeout,
Duration minRequestInterval) {
public boolean configured() { return enabled && queryUrl != null; }
public Duration effectiveConnectTimeout() {
return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10);
}
public Duration effectiveRequestTimeout() {
return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30);
}
public Duration effectiveMinRequestInterval() {
return minRequestInterval != null && !minRequestInterval.isNegative()
? minRequestInterval : Duration.ofSeconds(5);
}
private static boolean positive(Duration value) {
return value != null && !value.isNegative() && !value.isZero();
}
}
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
@@ -0,0 +1,15 @@
package com.baiyue.integration.domain;
import java.util.Locale;
public enum CarrierScheduleExceptionCode {
CUSTOMS_HOLD,
TERMINAL_HOLD,
DOCUMENT_HOLD,
UNKNOWN_HOLD;
public static boolean supports(String value) {
try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; }
catch (IllegalArgumentException exception) { return false; }
}
}
@@ -5,10 +5,14 @@ import java.util.Locale;
public enum CarrierScheduleStandardNode {
DEPARTED_WAREHOUSE,
AT_ORIGIN_PORT,
LOADED_ON_VESSEL,
DEPARTED_ORIGIN_PORT,
IN_TRANSIT,
ARRIVED,
PICKUP_READY;
ARRIVED_DESTINATION_PORT,
DISCHARGED_FROM_VESSEL,
PICKUP_READY,
PICKED_UP,
EMPTY_RETURNED;
public static boolean supports(String value) {
try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; }
@@ -14,10 +14,16 @@ public final class CarrierScheduleDtos {
@NotBlank @Size(max = 128) String trackingNo) { }
public record QueryResult(String providerCode, String outcome, List<Observation> observations,
List<ExceptionObservation> exceptions,
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
public record Observation(String providerEventId, String providerNodeCode, String standardNode,
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
public record ExceptionObservation(String providerEventId, String providerStatus,
String exceptionCode, boolean active, LocalDateTime observedAt,
String locationCode, String locationName, String sourceReference,
String rawPayloadHash) { }
}
@@ -4,6 +4,8 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import org.springframework.stereotype.Component;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import java.time.LocalDateTime;
import java.util.List;
@@ -15,6 +17,7 @@ import java.util.Set;
* an explicit provider-unavailable outcome until a documented provider API adapter is configured.
*/
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
private static final Set<String> CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
@@ -25,11 +28,11 @@ public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleP
@Override
public QueryResult query(QueryRequest request) {
return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(),
return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), List.of(),
"PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取",
LocalDateTime.now());
}
@Override
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); }
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_FALLBACK", true, false); }
}
@@ -0,0 +1,202 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.EvergreenTrackingClient.EvergreenTrackingException;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.util.HtmlUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
@Order(8)
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 Pattern TABLE = Pattern.compile("(?is)<table\\b[^>]*>(.*?)</table>");
private static final Pattern ROW = Pattern.compile("(?is)<tr\\b[^>]*>(.*?)</tr>");
private static final Pattern CELL = Pattern.compile("(?is)<t[hd]\\b[^>]*>(.*?)</t[hd]>");
private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$");
private static final Set<String> REQUIRED_HEADERS = Set.of("箱号", "柜型", "日期", "货柜动态", "地点", "船名航次", "METHOD", "VGM");
private final EvergreenTrackingClient client;
public EvergreenCarrierScheduleProviderAdapter(EvergreenTrackingClient client) { this.client = client; }
@Override
public boolean supports(String carrierCode) {
return client.configured() && carrierCode != null && "EMC".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
}
@Override
public QueryResult query(QueryRequest request) {
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
try {
EvergreenTrackingClient.TrackingResponse response = client.query(request);
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
List.of(), null, null, queriedAt);
} catch (EvergreenTrackingException exception) {
return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(),
exception.getMessage(), queriedAt);
} catch (EvergreenParseException exception) {
return new QueryResult(PROVIDER, "MANUAL_REQUIRED", List.of(), List.of(), exception.code,
exception.getMessage(), queriedAt);
}
}
List<Observation> observations(String containerNo, EvergreenTrackingClient.TrackingResponse response,
LocalDateTime queriedAt) {
if (response == null) return List.of();
String container = normalize(containerNo);
TrackingRow row = trackingRow(response.containerHtml(), container);
if (row == null) throw new EvergreenParseException("EMC_CONTAINER_ROW_NOT_FOUND", "长荣 HTML 未找到目标柜行");
String rawHash = sha256(response.rawBody() == null
? safe(response.containerHtml()) + safe(response.billHtml()) : response.rawBody());
List<Observation> facts = new ArrayList<>();
if (LOADED.equals(normalizeText(row.status()))) {
LocalDate date = parseDay(row.date());
if (date == null) throw new EvergreenParseException("EMC_DAY_PARSE_FAILED", "长荣货柜动态日期无法解析");
add(facts, container, "LOADED_FCL_ON_VESSEL", "LOADED_ON_VESSEL", queriedAt, date,
false, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash);
}
String estimatedRaw = estimatedLoadingDate(response.billHtml());
LocalDate estimated = parseDay(estimatedRaw);
if (estimated != null) {
add(facts, container, "ESTIMATED_LOADING_DATE", "LOADED_ON_VESSEL", queriedAt, estimated,
true, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash);
}
facts.sort(Comparator.comparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt()));
return List.copyOf(facts);
}
static String standardNode(String status) {
return LOADED.equals(normalizeText(status)) ? "LOADED_ON_VESSEL" : null;
}
private static void add(List<Observation> facts, String container, String providerNode, String standardNode,
LocalDateTime queriedAt, LocalDate day, boolean estimated, String location,
String vesselVoyage, String billSuffix, String rawHash) {
LocalDateTime eventAt = day.atStartOfDay();
String rawDate = day.format(DAY).toUpperCase(Locale.ENGLISH);
String reference = "EMC:CNTR:" + container + "|BL_SUFFIX:" + safe(billSuffix)
+ "|DATE:" + rawDate + "|PRECISION:DAY|ZONE:Asia/Shanghai";
String eventId = sha256(container + '|' + providerNode + '|' + rawDate + '|'
+ normalizeText(location) + '|' + normalizeText(vesselVoyage));
facts.add(new Observation(eventId, providerNode, standardNode, queriedAt,
estimated ? eventAt : null, estimated ? null : eventAt, countryCode(location), blank(location),
reference, rawHash));
}
static TrackingRow trackingRow(String html, String targetContainer) {
Matcher tables = TABLE.matcher(html == null ? "" : html);
while (tables.find()) {
List<List<String>> rows = rows(tables.group(1));
for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) {
Map<String, Integer> header = header(rows.get(headerIndex));
if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue;
for (int index = headerIndex + 1; index < rows.size(); index++) {
List<String> cells = rows.get(index);
String container = cell(cells, header.get("箱号"));
if (normalize(container).equals(targetContainer)) {
return new TrackingRow(container, cell(cells, header.get("日期")),
cell(cells, header.get("货柜动态")), cell(cells, header.get("地点")),
cell(cells, header.get("船名航次")));
}
}
}
}
return null;
}
static String estimatedLoadingDate(String html) {
for (List<String> row : allRows(html)) {
for (int index = 0; index + 1 < row.size(); index++) {
String label = row.get(index).replaceAll("\\s+", "");
if ("预计装船日".equals(label) || "預計裝船日".equals(label)) return row.get(index + 1).trim();
}
}
return null;
}
private static List<List<String>> allRows(String html) {
List<List<String>> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html);
while (tables.find()) values.addAll(rows(tables.group(1))); return values;
}
private static List<List<String>> rows(String table) {
List<List<String>> values = new ArrayList<>(); Matcher rows = ROW.matcher(table);
while (rows.find()) {
List<String> cells = new ArrayList<>(); Matcher cell = CELL.matcher(rows.group(1));
while (cell.find()) cells.add(htmlText(cell.group(1))); if (!cells.isEmpty()) values.add(List.copyOf(cells));
}
return values;
}
private static Map<String, Integer> header(List<String> cells) {
Map<String, Integer> values = new HashMap<>();
for (int index = 0; index < cells.size(); index++) values.put(normalizeHeader(cells.get(index)), index);
return values;
}
private static String normalizeHeader(String value) {
String text = value == null ? "" : value.replaceAll("\\s+", "").trim();
return text.chars().allMatch(ch -> ch < 128) ? text.toUpperCase(Locale.ROOT) : text;
}
private static String cell(List<String> cells, Integer index) {
return index == null || index < 0 || index >= cells.size() ? "" : cells.get(index).trim();
}
private static String htmlText(String value) {
return HtmlUtils.htmlUnescape(value.replaceAll("(?is)<[^>]+>", " ")).replace('\u00a0', ' ')
.replaceAll("\\s+", " ").trim();
}
private static LocalDate parseDay(String value) {
if (value == null || value.isBlank()) return null;
try { return LocalDate.parse(value.trim(), DAY); }
catch (DateTimeParseException exception) { return null; }
}
private static String countryCode(String location) {
Matcher matcher = COUNTRY_CODE.matcher(location == null ? "" : location.trim().toUpperCase(Locale.ROOT));
return matcher.find() ? matcher.group(1) : null;
}
private static String normalizeText(String value) {
return value == null ? "" : HtmlUtils.htmlUnescape(value).trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
}
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String safe(String value) { return value == null ? "" : value; }
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
record TrackingRow(String containerNo, String date, String status, String location, String vesselVoyage) { }
private static final class EvergreenParseException extends RuntimeException {
private final String code;
private EvergreenParseException(String code, String message) { super(message); this.code = code; }
}
@Override
public ProviderStatus status() {
return ProviderStatus.of("CARRIER_SCHEDULE_EMC", client.configured(), client.configured());
}
}
@@ -0,0 +1,151 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.util.HtmlUtils;
import java.io.IOException;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class EvergreenTrackingClient {
private static final Pattern INPUT = Pattern.compile("(?is)<input\\b[^>]*>");
private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2");
private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})");
private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&amp;)(?:BL|bl_no)=(\\d{12})");
private final Object requestLock = new Object();
@Resource private IntegrationProperties properties;
private IntegrationProperties.CarrierSchedule.Evergreen config;
private HttpClient httpClient;
private long nextRequestAtMillis;
@PostConstruct
void initialize() {
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen();
httpClient = HttpClient.newBuilder()
.connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL).build();
}
public boolean configured() { return config != null && config.configured(); }
public TrackingResponse query(QueryRequest request) {
if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用");
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询");
}
synchronized (requestLock) {
waitForSlot();
try {
String container = normalize(request.trackingNo());
String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER");
String bill = relatedBill(containerHtml);
if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核");
String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL");
return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4),
containerHtml + billHtml);
} finally {
nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis();
}
}
}
private String post(String form, String stage) {
try {
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
.timeout(config.effectiveRequestTimeout()).header("Accept", "text/html")
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 429 || response.statusCode() >= 500) {
throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null);
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求");
}
if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容");
return response.body();
} catch (EvergreenTrackingException exception) {
throw exception;
} catch (HttpTimeoutException exception) {
throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception);
} catch (IOException exception) {
throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception);
}
}
static String relatedBill(String html) {
Matcher inputs = INPUT.matcher(html == null ? "" : html);
while (inputs.find()) {
String name = attribute(inputs.group(), "name");
String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value"));
if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value;
}
String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html);
Matcher action = DISP_INFO.matcher(decoded);
if (action.find()) return action.group(1);
Matcher query = BL_QUERY.matcher(decoded);
return query.find() ? query.group(1) : null;
}
private static String attribute(String tag, String expected) {
Matcher attributes = ATTRIBUTE.matcher(tag);
while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim();
return "";
}
private static boolean looksLikeHtml(String value) {
String lower = value == null ? "" : value.toLowerCase(Locale.ROOT);
return lower.contains("<html") || lower.contains("<table") || lower.contains("<input");
}
private void waitForSlot() {
long wait = nextRequestAtMillis - System.currentTimeMillis();
if (wait <= 0) return;
try { Thread.sleep(wait); }
catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw retryable("EMC_INTERRUPTED", "等待长荣查询限流窗口时被中断", exception);
}
}
private static void manual(String code, String message) {
throw new EvergreenTrackingException("MANUAL_REQUIRED", code, message);
}
private static EvergreenTrackingException retryable(String code, String message, Throwable cause) {
return new EvergreenTrackingException("RETRYABLE_FAILURE", code, message, cause);
}
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
public record TrackingResponse(String containerHtml, String billHtml, String billSuffix, String rawBody) {
@Override public String toString() {
return "TrackingResponse[billSuffix=" + billSuffix + ",html=<redacted>]";
}
}
public static final class EvergreenTrackingException extends RuntimeException {
private final String outcome; private final String code;
public EvergreenTrackingException(String outcome, String code, String message) {
super(message); this.outcome = outcome; this.code = code;
}
public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) {
super(message, cause); this.outcome = outcome; this.code = code;
}
public String outcome() { return outcome; }
public String code() { return code; }
}
}
@@ -0,0 +1,161 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.HedeTrackingClient.HedeTrackingException;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
@Order(10)
public class HedeCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
private static final String PROVIDER = "HEDE_PUBLIC_HTML";
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Pattern ROW_PATTERN = Pattern.compile(
"<tr[^>]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)</tr>",
Pattern.CASE_INSENSITIVE);
private static final Pattern CELL_PATTERN = Pattern.compile("<td[^>]*>([\\s\\S]*?)</td>",
Pattern.CASE_INSENSITIVE);
private final HedeTrackingClient client;
public HedeCarrierScheduleProviderAdapter(HedeTrackingClient client) { this.client = client; }
@Override
public boolean supports(String carrierCode) {
return client.configured() && carrierCode != null && "HEDE".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
}
@Override
public QueryResult query(QueryRequest request) {
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_CONTAINER_REQUIRED",
"合德自动节点当前仅支持按柜号精确解析,提单可能返回多个柜子", queriedAt);
}
try {
HedeTrackingClient.TrackingResponse response = client.query(request);
List<Observation> observations = observations(request.trackingNo(), response.html(), queriedAt);
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, List.of(),
null, null, queriedAt);
} catch (HedeTrackingException exception) {
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt);
} catch (Exception exception) {
return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_HTML_SCHEMA_CHANGED",
"合德查询 HTML 结构无法解析", queriedAt);
}
}
List<Observation> observations(String containerNo, String html) throws Exception {
return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC));
}
private List<Observation> observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception {
String normalizedContainer = normalizeIdentifier(containerNo);
List<HedeRow> matched = rows(html).stream()
.filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList();
if (matched.isEmpty()) return List.of();
String rawHash = sha256(html);
List<Observation> values = new ArrayList<>();
for (HedeRow row : matched) {
String reference = "HEDE:" + row.containerNo() + ':' + row.vessel() + '/' + row.voyage();
addEstimated(values, row, "ESTIMATED_DEPARTURE", "DEPARTED_ORIGIN_PORT",
row.estimatedDeparture(), queriedAt, reference, rawHash);
addEstimated(values, row, "ESTIMATED_ARRIVAL", "ARRIVED_DESTINATION_PORT",
row.estimatedArrival(), queriedAt, reference, rawHash);
addActual(values, row, "FULL_GATE_IN", "AT_ORIGIN_PORT", row.fullGateIn(), queriedAt, reference, rawHash);
addActual(values, row, "LOADED", "LOADED_ON_VESSEL", row.loadedAt(), queriedAt, reference, rawHash);
addActual(values, row, "DISCHARGED", "DISCHARGED_FROM_VESSEL", row.dischargedAt(), queriedAt, reference, rawHash);
addActual(values, row, "FULL_GATE_OUT", "PICKED_UP", row.fullGateOut(), queriedAt, reference, rawHash);
addActual(values, row, "EMPTY_RETURNED", "EMPTY_RETURNED", row.emptyReturnedAt(), queriedAt, reference, rawHash);
}
values.sort(Comparator.comparing(Observation::observedAt));
return List.copyOf(values);
}
private static void addActual(List<Observation> values, HedeRow row, String providerNode,
String standardNode, String timestamp, LocalDateTime queriedAt,
String reference, String rawHash) {
LocalDateTime actualAt = parseDate(timestamp);
if (actualAt == null) return;
String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp;
values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt,
null, actualAt, null, null, reference, rawHash));
}
private static void addEstimated(List<Observation> values, HedeRow row, String providerNode,
String standardNode, String timestamp, LocalDateTime queriedAt,
String reference, String rawHash) {
LocalDateTime estimatedAt = parseDate(timestamp);
if (estimatedAt == null) return;
String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp;
values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt,
estimatedAt, null, null, null, reference, rawHash));
}
static List<HedeRow> rows(String html) {
List<HedeRow> rows = new ArrayList<>();
Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html);
while (rowMatcher.find()) {
List<String> cells = new ArrayList<>();
Matcher cellMatcher = CELL_PATTERN.matcher(rowMatcher.group(1));
while (cellMatcher.find()) cells.add(htmlText(cellMatcher.group(1)));
if (cells.size() >= 12) rows.add(row(cells));
}
return List.copyOf(rows);
}
private static String htmlText(String value) {
return value.replaceAll("(?is)<[^>]+>", " ")
.replace("&nbsp;", " ").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", "\"").replace("&#39;", "'").trim().replaceAll("\\s+", " ");
}
private static HedeRow row(List<String> cells) {
return new HedeRow(cells.get(0), cells.get(1), cells.get(2), cells.get(3), cells.get(4), cells.get(5),
cells.get(6), cells.get(7), cells.get(8), cells.get(9), cells.get(10), cells.get(11));
}
private static LocalDateTime parseDate(String value) {
if (value == null || value.isBlank()) return null;
try { return LocalDateTime.parse(value.trim(), DATE_TIME); }
catch (DateTimeParseException exception) { return null; }
}
private static String normalizeIdentifier(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) { throw new IllegalStateException(exception); }
}
@Override
public ProviderStatus status() {
return ProviderStatus.of("CARRIER_SCHEDULE_HEDE", client.configured(), client.configured());
}
record HedeRow(String billNo, String containerNo, String vessel, String voyage,
String estimatedDeparture, String estimatedArrival, String emptyGateOut,
String fullGateIn, String loadedAt, String dischargedAt,
String fullGateOut, String emptyReturnedAt) { }
}
@@ -0,0 +1,123 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
@Component
public class HedeTrackingClient {
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@Resource private IntegrationProperties properties;
private IntegrationProperties.CarrierSchedule.Hede config;
private HttpClient httpClient;
private final Object requestLock = new Object();
private long nextRequestAtMillis;
@PostConstruct
void initialize() {
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().hede();
httpClient = HttpClient.newBuilder()
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
public boolean configured() { return config != null && config.configured(); }
public TrackingResponse query(QueryRequest request) {
if (!configured()) {
throw new HedeTrackingException("HEDE_NOT_CONFIGURED", false, "合德网页查询能力未启用或地址未配置");
}
synchronized (requestLock) {
waitForRequestSlot();
try {
return execute(request);
} finally {
nextRequestAtMillis = System.currentTimeMillis()
+ config.effectiveMinRequestInterval().toMillis();
}
}
}
private TrackingResponse execute(QueryRequest request) {
String trackingType = request.trackingType().trim().toUpperCase(Locale.ROOT);
String billNo = "BILL_OF_LADING".equals(trackingType) ? request.trackingNo().trim() : "";
String containerNo = "CONTAINER_NO".equals(trackingType) ? request.trackingNo().trim() : "";
if (billNo.isEmpty() && containerNo.isEmpty()) {
throw new HedeTrackingException("HEDE_TRACKING_TYPE_UNSUPPORTED", false,
"合德仅支持提单号或柜号查询");
}
String form = "billno=" + encode(billNo) + "&cntr=" + encode(containerNo)
+ "&selecttype=1&resultlist=1&paramtype=0";
try {
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
.timeout(config.effectiveRequestTimeout())
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
ensureHttp(response.statusCode());
return new TrackingResponse(response.body());
} catch (HedeTrackingException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new HedeTrackingException("HEDE_INTERRUPTED", true, "合德查询被中断", exception);
} catch (IOException exception) {
throw new HedeTrackingException("HEDE_IO_FAILED", true, "合德查询网络失败", exception);
}
}
private void waitForRequestSlot() {
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
if (waitMillis <= 0) return;
try {
Thread.sleep(waitMillis);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new HedeTrackingException("HEDE_INTERRUPTED", true, "等待合德查询限流窗口时被中断", exception);
}
}
private static void ensureHttp(int status) {
if (status >= 200 && status < 300) return;
if (status == 429) {
throw new HedeTrackingException("HEDE_RATE_LIMITED", true, "合德查询触发访问频率限制");
}
throw new HedeTrackingException("HEDE_HTTP_" + status, status >= 500,
"合德查询返回异常 HTTP 状态:" + status);
}
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
public record TrackingResponse(String html) { }
public static final class HedeTrackingException extends RuntimeException {
private final String code;
private final boolean retryable;
public HedeTrackingException(String code, boolean retryable, String message) {
super(message); this.code = code; this.retryable = retryable;
}
public HedeTrackingException(String code, boolean retryable, String message, Throwable cause) {
super(message, cause); this.code = code; this.retryable = retryable;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
}
}
@@ -0,0 +1,220 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.MatsonTrackingClient.MatsonTrackingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
@Component
@Order(0)
public class MatsonCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
private static final String PROVIDER = "MATSON_PUBLIC_TRACKING";
private final MatsonTrackingClient client;
public MatsonCarrierScheduleProviderAdapter(MatsonTrackingClient client) {
this.client = client;
}
@Override
public boolean supports(String carrierCode) {
return client.configured() && carrierCode != null && "MATSON".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
}
@Override
public QueryResult query(QueryRequest request) {
try {
MatsonTrackingClient.TrackingResponse response = client.query(request);
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
List<Observation> observations = observations(request, response.payload(), response.rawBody(), queriedAt);
List<ExceptionObservation> exceptions = exceptions(request, response.payload(), response.rawBody(), queriedAt);
return new QueryResult(PROVIDER, observations.isEmpty() && exceptions.isEmpty() ? "NO_CHANGE" : "SUCCESS",
observations, exceptions,
null, null, queriedAt);
} catch (MatsonTrackingException exception) {
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
List.of(), List.of(), exception.code(), exception.getMessage(), LocalDateTime.now(ZoneOffset.UTC));
}
}
List<Observation> observations(QueryRequest request, JsonNode payload, String rawBody,
LocalDateTime queriedAt) {
if (payload == null || !payload.isObject()) return List.of();
if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) {
return List.of();
}
String rawHash = sha256(rawBody == null ? payload.toString() : rawBody);
String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo());
List<Observation> values = new ArrayList<>();
JsonNode events = payload.path("events");
if (events.isArray()) for (JsonNode event : events) {
long epochMillis = event.path("date").asLong(0);
String rawStatus = text(event, "status", "").trim();
String standardNode = standardNode(rawStatus);
if (epochMillis <= 0 || standardNode == null) continue;
LocalDateTime actualAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC);
String locationName = text(event, "location", "").trim();
String locationCode = locationCode(payload, locationName, standardNode);
String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|'
+ normalizeStatus(rawStatus) + '|' + locationName.toUpperCase(Locale.ROOT);
values.add(new Observation(sha256(eventIdentity), truncate(normalizeStatus(rawStatus), 64), standardNode,
queriedAt, null, actualAt, blankToNull(locationCode), blankToNull(locationName),
sourceReference, rawHash));
}
LocalDateTime eta = parseDate(payload.get("vesselETA"));
if (eta != null) {
String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + "|VESSEL_ETA|" + eta;
values.add(new Observation(sha256(eventIdentity), "VESSEL_ETA", "ARRIVED_DESTINATION_PORT",
queriedAt, eta, null, blankToNull(text(payload, "destCode", "")),
blankToNull(text(payload, "destPort", "")), sourceReference, rawHash));
}
values.sort(Comparator.comparing(Observation::observedAt));
return List.copyOf(values);
}
List<ExceptionObservation> exceptions(QueryRequest request, JsonNode payload, String rawBody,
LocalDateTime queriedAt) {
if (payload == null || !payload.isObject()) return List.of();
if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) {
return List.of();
}
String rawHash = sha256(rawBody == null ? payload.toString() : rawBody);
String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo());
List<ExceptionObservation> values = new ArrayList<>();
JsonNode events = payload.path("events");
if (!events.isArray()) return List.of();
for (JsonNode event : events) {
String rawStatus = text(event, "status", "").trim();
String status = normalizeStatus(rawStatus);
String exceptionCode = exceptionCode(status);
long epochMillis = event.path("date").asLong(0);
if (exceptionCode == null || epochMillis <= 0) continue;
String locationName = text(event, "location", "").trim();
String identity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|'
+ status + '|' + locationName.toUpperCase(Locale.ROOT);
values.add(new ExceptionObservation(sha256(identity), truncate(rawStatus, 128), exceptionCode, true,
queriedAt, blankToNull(locationCode(payload, locationName, "PICKUP_READY")),
blankToNull(locationName), sourceReference, rawHash));
}
values.sort(Comparator.comparing(ExceptionObservation::observedAt));
return List.copyOf(values);
}
static String standardNode(String status) {
String value = normalizeStatus(status);
return switch (value) {
case "INGATE" -> "AT_ORIGIN_PORT";
case "LOAD TO VESSEL" -> "LOADED_ON_VESSEL";
case "VESSEL DEPARTURE" -> "DEPARTED_ORIGIN_PORT";
case "IN TRANSIT" -> "IN_TRANSIT";
case "VESSEL ARRIVAL", "ARRIVED AT DESTINATION" -> "ARRIVED_DESTINATION_PORT";
case "DISCHARGE FROM VESSEL" -> "DISCHARGED_FROM_VESSEL";
case "AVAILABLE FOR PICKUP" -> "PICKUP_READY";
case "OUTGATE" -> "PICKED_UP";
case "EMPTY RETURN", "EMPTY RETURNED" -> "EMPTY_RETURNED";
default -> null;
};
}
static String exceptionCode(String status) {
return "CONTAINER ON HOLD - CUSTOMS CLEARANCE".equals(normalizeStatus(status)) ? "CUSTOMS_HOLD" : null;
}
private static boolean matchesContainer(String requested, JsonNode payload) {
String actual = text(payload, "containerNumber", "") + text(payload, "checkDigit", "");
return normalizeIdentifier(requested).equals(normalizeIdentifier(actual));
}
private static String locationCode(JsonNode payload, String location, String standardNode) {
String normalized = location.toUpperCase(Locale.ROOT);
String originName = text(payload, "originPort", "").toUpperCase(Locale.ROOT);
String loadPort = text(payload, "loadPort", "").toUpperCase(Locale.ROOT);
String destinationName = text(payload, "destPort", "").toUpperCase(Locale.ROOT);
String dischargePort = text(payload, "dischargePort", "").toUpperCase(Locale.ROOT);
if (!normalized.isBlank() && (normalized.equals(originName) || normalized.equals(loadPort))) {
return text(payload, "originCode", "");
}
if (!normalized.isBlank() && (normalized.equals(destinationName) || normalized.equals(dischargePort))) {
return text(payload, "destCode", "");
}
if ("ARRIVED_DESTINATION_PORT".equals(standardNode)
|| "DISCHARGED_FROM_VESSEL".equals(standardNode) || "PICKUP_READY".equals(standardNode)
|| "PICKED_UP".equals(standardNode) || "EMPTY_RETURNED".equals(standardNode)) {
return text(payload, "destCode", "");
}
return "";
}
private static String normalizeStatus(String value) {
return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
}
private static String normalizeIdentifier(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String text(JsonNode node, String field, String fallback) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? fallback : value.asText(fallback);
}
private static LocalDateTime parseDate(JsonNode value) {
if (value == null || value.isNull()) return null;
if (value.isNumber()) {
long epoch = value.asLong();
if (epoch <= 0) return null;
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch),
ZoneOffset.UTC);
}
String text = value.asText("").trim();
if (text.isEmpty()) return null;
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
catch (DateTimeParseException ignored) { }
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"))) {
try { return LocalDateTime.parse(text, formatter); }
catch (DateTimeParseException ignored) { }
}
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE,
DateTimeFormatter.ofPattern("MM/dd/yy"), DateTimeFormatter.ofPattern("MM/dd/yyyy"))) {
try { return LocalDate.parse(text, formatter).atStartOfDay(); }
catch (DateTimeParseException ignored) { }
}
return null;
}
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); }
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
@Override
public ProviderStatus status() {
return ProviderStatus.of("CARRIER_SCHEDULE_MATSON", client.configured(), client.configured());
}
}
@@ -0,0 +1,172 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Component
public class MatsonTrackingClient {
private static final Pattern TTR_PATTERN = Pattern.compile(
"id=[\\\"']ttr[\\\"'][^>]*value=[\\\"']([^\\\"']+)[\\\"']", Pattern.CASE_INSENSITIVE);
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@Resource private IntegrationProperties properties;
@Resource private ObjectMapper objectMapper;
private IntegrationProperties.CarrierSchedule.Matson config;
private final Object requestLock = new Object();
private long nextRequestAtMillis;
@PostConstruct
void initialize() {
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().matson();
}
public boolean configured() {
return config != null && config.configured();
}
public TrackingResponse query(QueryRequest request) {
if (!configured()) {
throw new MatsonTrackingException("MATSON_NOT_CONFIGURED", false,
"美森公开查询能力未启用或地址未配置");
}
synchronized (requestLock) {
waitForRequestSlot();
try {
return execute(request);
} finally {
nextRequestAtMillis = System.currentTimeMillis()
+ config.effectiveMinRequestInterval().toMillis();
}
}
}
private TrackingResponse execute(QueryRequest request) {
CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
HttpClient client = HttpClient.newBuilder()
.cookieHandler(cookies)
.connectTimeout(config.effectiveConnectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
try {
HttpResponse<String> page = client.send(HttpRequest.newBuilder(config.pageUrl())
.timeout(config.effectiveRequestTimeout())
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.GET().build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
ensureHttp(page.statusCode(), "MATSON_PAGE");
Matcher matcher = TTR_PATTERN.matcher(page.body());
if (!matcher.find() || matcher.group(1).isBlank()) {
throw new MatsonTrackingException("MATSON_TTR_MISSING", true,
"美森查询页未返回动态校验参数");
}
String form = "container_trackingnumber=" + encode(request.trackingNo().trim())
+ "&trackingVal=" + trackingValue(request.trackingType())
+ "&ttr=" + encode(matcher.group(1));
HttpResponse<String> response = client.send(HttpRequest.newBuilder(config.queryUrl())
.timeout(config.effectiveRequestTimeout())
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Origin", origin(config.pageUrl()))
.header("Referer", config.pageUrl().toString())
.header("X-Requested-With", "XMLHttpRequest")
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
ensureHttp(response.statusCode(), "MATSON_QUERY");
JsonNode payload = objectMapper.readTree(response.body());
return new TrackingResponse(payload, response.body());
} catch (MatsonTrackingException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "美森查询被中断", exception);
} catch (IOException exception) {
throw new MatsonTrackingException("MATSON_IO_FAILED", true, "美森查询网络或响应解析失败", exception);
}
}
private void waitForRequestSlot() {
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
if (waitMillis <= 0) return;
try {
Thread.sleep(waitMillis);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "等待美森查询限流窗口时被中断", exception);
}
}
private static void ensureHttp(int status, String stage) {
if (status >= 200 && status < 300) return;
if (status == 429) {
throw new MatsonTrackingException("MATSON_RATE_LIMITED", true, "美森查询触发访问频率限制");
}
if (status == 403) {
throw new MatsonTrackingException("MATSON_SESSION_REJECTED", true,
"美森拒绝当前网页会话,请稍后重新获取动态参数");
}
throw new MatsonTrackingException(stage + "_HTTP_" + status, status >= 500,
"美森查询返回异常 HTTP 状态:" + status);
}
private static String trackingValue(String trackingType) {
return switch (trackingType.trim().toUpperCase(Locale.ROOT)) {
case "CONTAINER_NO" -> "tckconta";
case "BOOKING_NO" -> "tckbok";
case "BILL_OF_LADING" -> "tckbil";
default -> throw new MatsonTrackingException("MATSON_TRACKING_TYPE_UNSUPPORTED", false,
"美森不支持该跟踪类型:" + trackingType);
};
}
private static String origin(URI uri) {
int port = uri.getPort();
return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
public record TrackingResponse(JsonNode payload, String rawBody) { }
public static final class MatsonTrackingException extends RuntimeException {
private final String code;
private final boolean retryable;
public MatsonTrackingException(String code, boolean retryable, String message) {
super(message); this.code = code; this.retryable = retryable;
}
public MatsonTrackingException(String code, boolean retryable, String message, Throwable cause) {
super(message, cause); this.code = code; this.retryable = retryable;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
}
}
@@ -0,0 +1,154 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.SmlTrackingClient.SmlTrackingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Component
@Order(5)
public class SmlCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
private static final String PROVIDER = "SML_PUBLIC_TRACKING";
private static final Map<String, String> NODE_MAP = Map.of(
"FOTMAD", "AT_ORIGIN_PORT",
"FLVMLO", "LOADED_ON_VESSEL",
"FLVMDO", "DEPARTED_ORIGIN_PORT",
"FUVMAD", "ARRIVED_DESTINATION_PORT",
"FUVMBD", "ARRIVED_DESTINATION_PORT",
"FUVMUD", "DISCHARGED_FROM_VESSEL",
"MITYAD", "EMPTY_RETURNED");
private final SmlTrackingClient client;
public SmlCarrierScheduleProviderAdapter(SmlTrackingClient client) { this.client = client; }
@Override
public boolean supports(String carrierCode) {
return client.configured() && carrierCode != null && "SML".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
}
@Override
public QueryResult query(QueryRequest request) {
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
try {
SmlTrackingClient.TrackingResponse response = client.query(request);
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
List.of(), null, null, queriedAt);
} catch (SmlTrackingException exception) {
return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE",
List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt);
}
}
List<Observation> observations(String containerNo, SmlTrackingClient.TrackingResponse response) {
return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC));
}
private List<Observation> observations(String containerNo, SmlTrackingClient.TrackingResponse response,
LocalDateTime queriedAt) {
if (response == null || response.timeline() == null) return List.of();
String rawHash = sha256(response.rawBody() == null ? response.timeline().toString() : response.rawBody());
String reference = "SML:" + response.bookingNo() + ':' + response.copNo();
Map<String, Observation> facts = new LinkedHashMap<>();
collect(response.timeline(), containerNo, reference, rawHash, queriedAt, facts);
return facts.values().stream().sorted(Comparator.comparing(Observation::observedAt)).toList();
}
private static void collect(JsonNode node, String containerNo, String reference, String rawHash,
LocalDateTime queriedAt,
Map<String, Observation> facts) {
if (node == null) return;
if (node.isObject()) {
String providerNode = directText(node, "actStsMapgCd", "actCd", "actStsCd", "eventCode");
String actType = directText(node, "actTpCd", "actualType");
String standardNode = providerNode == null ? null : NODE_MAP.get(providerNode.toUpperCase(Locale.ROOT));
LocalDateTime eventAt = parseDate(directText(node, "eventDt", "actDt", "skdDt", "eventDate", "date"));
if (standardNode != null && eventAt != null && ("A".equalsIgnoreCase(actType) || "E".equalsIgnoreCase(actType))) {
String locationCode = directText(node, "locCd", "locationCode", "portCd");
String locationName = directText(node, "locNm", "locationName", "portNm");
String identity = normalize(containerNo) + '|' + providerNode.toUpperCase(Locale.ROOT) + '|'
+ actType.toUpperCase(Locale.ROOT) + '|' + eventAt + '|' + normalize(locationCode) + '|'
+ normalize(locationName);
String eventId = sha256(identity);
boolean actual = "A".equalsIgnoreCase(actType);
facts.putIfAbsent(eventId, new Observation(eventId, providerNode.toUpperCase(Locale.ROOT),
standardNode, queriedAt,
actual ? null : eventAt, actual ? eventAt : null, blank(locationCode), blank(locationName),
reference, rawHash));
}
Iterator<JsonNode> children = node.elements();
while (children.hasNext()) collect(children.next(), containerNo, reference, rawHash, queriedAt, facts);
} else if (node.isArray()) for (JsonNode child : node) {
collect(child, containerNo, reference, rawHash, queriedAt, facts);
}
}
static String standardNode(String providerCode) {
return providerCode == null ? null : NODE_MAP.get(providerCode.trim().toUpperCase(Locale.ROOT));
}
private static String directText(JsonNode node, String... names) {
for (String name : names) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
var field = fields.next();
if (field.getKey().equalsIgnoreCase(name) && field.getValue().isValueNode()) {
String value = field.getValue().asText("").trim();
if (!value.isEmpty()) return value;
}
}
}
return null;
}
private static LocalDateTime parseDate(String value) {
if (value == null || value.isBlank()) return null;
String text = value.trim();
try {
long epoch = Long.parseLong(text);
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch),
ZoneOffset.UTC);
} catch (NumberFormatException ignored) { }
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
catch (DateTimeParseException ignored) { }
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("yyyyMMddHHmm"))) {
try { return LocalDateTime.parse(text, formatter); }
catch (DateTimeParseException ignored) { }
}
return null;
}
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
@Override
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_SML", client.configured(), client.configured()); }
}
@@ -0,0 +1,165 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URLEncoder;
import java.net.http.HttpTimeoutException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
@Component
public class SmlTrackingClient {
@Resource private IntegrationProperties properties;
@Resource private ObjectMapper objectMapper;
private IntegrationProperties.CarrierSchedule.Sml config;
private HttpClient httpClient;
private final Object requestLock = new Object();
private long nextRequestAtMillis;
@PostConstruct
void initialize() {
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().sml();
httpClient = HttpClient.newBuilder()
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL).build();
}
public boolean configured() { return config != null && config.configured(); }
public TrackingResponse query(QueryRequest request) {
if (!configured()) throw new SmlTrackingException("SML_NOT_CONFIGURED", false, "SML 查询能力未启用");
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
throw new SmlTrackingException("SML_CONTAINER_REQUIRED", false, "SML 自动节点只按柜号查询");
}
synchronized (requestLock) {
waitForSlot();
try {
String container = request.trackingNo().trim();
JsonResponse lookup = call("f_cmd=122&cntr_no=" + encode(container) + "&cust_cd=", "LOOKUP");
validateBusinessResult(lookup.payload());
String bookingNo = findText(lookup.payload(), "bkgNo", "bkg_no");
String copNo = findText(lookup.payload(), "copNo", "cop_no");
if (bookingNo == null || copNo == null) {
throw new SmlTrackingException("SML_BUSINESS_KEYS_MISSING", false,
"SML 查询结果缺少 bkgNo 或 copNo");
}
JsonResponse timeline = call("f_cmd=125&cntr_no=" + encode(container) + "&bkg_no="
+ encode(bookingNo) + "&cop_no=" + encode(copNo), "TIMELINE");
validateBusinessResult(timeline.payload());
JsonResponse schedule = call("f_cmd=124&bkg_no=" + encode(bookingNo), "SCHEDULE");
JsonResponse clearance = call("f_cmd=126&cntr_no=" + encode(container) + "&bkg_no="
+ encode(bookingNo), "CLEARANCE");
return new TrackingResponse(bookingNo, copNo, timeline.payload(), schedule.payload(),
clearance.payload(), lookup.raw() + timeline.raw() + schedule.raw() + clearance.raw());
} finally {
nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis();
}
}
}
private JsonResponse call(String form, String stage) {
try {
HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder(config.queryUrl())
.timeout(config.effectiveRequestTimeout())
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() == 429) throw new SmlTrackingException("SML_RATE_LIMITED", true, "SML 访问限流");
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new SmlTrackingException("SML_" + stage + "_HTTP_" + response.statusCode(),
response.statusCode() >= 500, "SML 查询返回异常 HTTP 状态:" + response.statusCode());
}
try { return new JsonResponse(objectMapper.readTree(response.body()), response.body()); }
catch (Exception exception) {
throw new SmlTrackingException("SML_" + stage + "_NON_JSON", true,
"SML 查询返回 HTML 或无效 JSON", exception);
}
} catch (SmlTrackingException exception) {
throw exception;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new SmlTrackingException("SML_INTERRUPTED", true, "SML 查询被中断", exception);
} catch (HttpTimeoutException exception) {
throw new SmlTrackingException("SML_" + stage + "_TIMEOUT", true, "SML 查询超时", exception);
} catch (IOException exception) {
throw new SmlTrackingException("SML_IO_FAILED", true, "SML 查询网络失败", exception);
}
}
private static void validateBusinessResult(JsonNode payload) {
if (payload == null || payload.isNull()) throw new SmlTrackingException("SML_EMPTY_RESULT", false, "SML 返回空结果");
String result = findText(payload, "TRANS_RESULT_KEY", "transResultKey");
if (result != null && !"S".equalsIgnoreCase(result)) {
throw new SmlTrackingException("SML_BUSINESS_FAILED", false, "SML 业务查询失败:" + result);
}
Long count = findLong(payload, "count", "totalCount");
if (count != null && count == 0L) throw new SmlTrackingException("SML_NOT_FOUND", false, "SML 未找到业务对象");
}
static String findText(JsonNode node, String... names) {
if (node == null) return null;
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
var field = fields.next();
for (String name : names) if (field.getKey().equalsIgnoreCase(name)
&& field.getValue().isValueNode() && !field.getValue().asText().isBlank()) {
return field.getValue().asText().trim();
}
String nested = findText(field.getValue(), names);
if (nested != null) return nested;
}
} else if (node.isArray()) for (JsonNode child : node) {
String nested = findText(child, names); if (nested != null) return nested;
}
return null;
}
private static Long findLong(JsonNode node, String... names) {
String value = findText(node, names);
if (value == null) return null;
try { return Long.parseLong(value); }
catch (NumberFormatException ignored) { return null; }
}
private void waitForSlot() {
long wait = nextRequestAtMillis - System.currentTimeMillis();
if (wait <= 0) return;
try { Thread.sleep(wait); }
catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new SmlTrackingException("SML_INTERRUPTED", true, "等待 SML 限流窗口时被中断", exception);
}
}
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
private record JsonResponse(JsonNode payload, String raw) { }
public record TrackingResponse(String bookingNo, String copNo, JsonNode timeline, JsonNode schedule,
JsonNode clearance, String rawBody) { }
public static final class SmlTrackingException extends RuntimeException {
private final String code; private final boolean retryable;
public SmlTrackingException(String code, boolean retryable, String message) {
super(message); this.code = code; this.retryable = retryable;
}
public SmlTrackingException(String code, boolean retryable, String message, Throwable cause) {
super(message, cause); this.code = code; this.retryable = retryable;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
}
}
@@ -0,0 +1,199 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.ZimTrackingClient.ContainerEvent;
import com.baiyue.integration.provider.ZimTrackingClient.RouteDetail;
import com.baiyue.integration.provider.ZimTrackingClient.ZimTrackingException;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Component
@Order(7)
public class ZimCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
private static final String PROVIDER = "ZIM_OFFICIAL_CARGO_TRACKING";
private static final Map<String, String> EVENT_MAP = Map.ofEntries(
Map.entry("EXPORT GATE-IN FROM CUSTOMER TO PORT OF LOADING", "AT_ORIGIN_PORT"),
Map.entry("EXPORT GATE-IN TO PORT OF LOADING", "AT_ORIGIN_PORT"),
Map.entry("CONTAINER WAS LOADED AT TRANSSHIPMENT PORT TO TRANSSHIPMENT PORT", "LOADED_ON_VESSEL"),
Map.entry("VESSEL DEPARTURE FROM FIRST TRANSSHIPMENT PORT TO NEXT TRANSSHIPMENT PORT", "IN_TRANSIT"),
Map.entry("CONTAINER LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"),
Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"),
Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING TO PORT OF DISCHARGE", "LOADED_ON_VESSEL"),
Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING", "DEPARTED_ORIGIN_PORT"),
Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING TO PORT OF DISCHARGE", "DEPARTED_ORIGIN_PORT"));
private final ZimTrackingClient client;
public ZimCarrierScheduleProviderAdapter(ZimTrackingClient client) { this.client = client; }
@Override
public boolean supports(String carrierCode) {
return client.configured() && carrierCode != null && "ZIM".equals(carrierCode.trim().toUpperCase(Locale.ROOT));
}
@Override
public QueryResult query(QueryRequest request) {
LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC);
try {
ZimTrackingClient.TrackingResponse response = client.query(request);
List<Observation> observations = observations(request.trackingNo(), response, queriedAt);
return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations,
List.of(), null, null, queriedAt);
} catch (ZimTrackingException exception) {
return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(),
exception.getMessage(), queriedAt);
}
}
List<Observation> observations(String containerNo, ZimTrackingClient.TrackingResponse response,
LocalDateTime queriedAt) {
if (response == null || response.payload() == null || response.payload().bkBlDetails() == null) return List.of();
String normalizedContainer = normalize(containerNo);
ZimTrackingClient.ContainerDetail unit = response.payload().bkBlDetails().safeContainers().stream()
.filter(value -> normalizedContainer.equals(normalize(value.containerNumber())))
.findFirst().orElse(null);
if (unit == null) return List.of();
String rawHash = sha256(response.rawBody() == null ? normalizedContainer : response.rawBody());
String reference = sourceReference(response.payload(), normalizedContainer);
Map<String, Observation> facts = new LinkedHashMap<>();
for (ContainerEvent event : unit.safeEvents()) {
String eventName = normalizeEvent(event.eventName());
String standardNode = EVENT_MAP.get(eventName);
LocalDateTime actualAt = parseDate(first(event.activityDateTz(), event.activityDate()));
if (standardNode == null || actualAt == null) continue;
add(facts, normalizedContainer, truncate(eventName, 64), standardNode, queriedAt, null, actualAt,
blank(event.portCode()), locationName(event.port(), event.country()), reference, rawHash,
event.vesselName(), event.voyage(), event.leg());
}
List<RouteDetail> routes = response.payload().safeRoutes();
RouteDetail firstSailing = routes.stream()
.filter(value -> parseDate(first(value.sailingDateTz(), value.sailingDateDT())) != null)
.findFirst().orElse(null);
if (firstSailing != null) {
addEstimated(facts, normalizedContainer, "ROUTE_POL_SAILING_PLAN", "DEPARTED_ORIGIN_PORT",
parseDate(first(firstSailing.sailingDateTz(), firstSailing.sailingDateDT())), queriedAt,
firstSailing, reference, rawHash);
}
RouteDetail lastArrival = null;
for (int index = routes.size() - 1; index >= 0; index--) {
RouteDetail candidate = routes.get(index);
if (parseDate(first(candidate.arrivalDateTz(), candidate.arrivalDateDT())) != null) {
lastArrival = candidate; break;
}
}
if (lastArrival != null) {
addEstimated(facts, normalizedContainer, "ROUTE_POD_ARRIVAL_PLAN", "ARRIVED_DESTINATION_PORT",
parseDate(first(lastArrival.arrivalDateTz(), lastArrival.arrivalDateDT())), queriedAt,
lastArrival, reference, rawHash);
}
if (response.payload().finalETA() != null) {
addEstimated(facts, normalizedContainer, "FINAL_ETA", "ARRIVED_DESTINATION_PORT",
parseDate(first(response.payload().finalETA().etaValue(), response.payload().finalETA().etaValueDate())),
queriedAt, null, reference, rawHash);
}
return facts.values().stream().sorted(Comparator.comparing(Observation::standardNode)
.thenComparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())).toList();
}
static String standardNode(String eventName) {
return EVENT_MAP.get(normalizeEvent(eventName));
}
private static void addEstimated(Map<String, Observation> facts, String container, String providerNode,
String standardNode, LocalDateTime estimatedAt, LocalDateTime queriedAt,
RouteDetail route, String reference, String rawHash) {
if (estimatedAt == null) return;
add(facts, container, providerNode, standardNode, queriedAt, estimatedAt, null,
route == null ? null : blank(route.portCode()),
route == null ? null : locationName(route.port(), route.country()), reference, rawHash,
route == null ? null : route.vesselName(), route == null ? null : route.voyage(),
route == null ? null : route.leg());
}
private static void add(Map<String, Observation> facts, String container, String providerNode,
String standardNode, LocalDateTime queriedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String reference, String rawHash, String vesselName, String voyage, String leg) {
LocalDateTime eventAt = actualAt == null ? estimatedAt : actualAt;
String eventId = sha256(container + '|' + providerNode + '|' + eventAt + '|'
+ normalize(locationCode) + '|' + normalize(locationName) + '|' + clean(vesselName) + '|'
+ clean(voyage) + '|' + clean(leg));
facts.putIfAbsent(eventId, new Observation(eventId, providerNode, standardNode, queriedAt,
estimatedAt, actualAt, locationCode, locationName, reference, rawHash));
}
private static String first(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) return value.trim();
}
return "";
}
private static String sourceReference(ZimTrackingClient.TracingPayload payload, String container) {
StringBuilder value = new StringBuilder("ZIM:CONTAINER:").append(container);
if (!clean(payload.bookingReference()).isEmpty()) value.append("|BOOKING:").append(clean(payload.bookingReference()));
if (!clean(payload.blReference()).isEmpty()) value.append("|BL:").append(clean(payload.blReference()));
return truncate(value.toString(), 500);
}
private static String locationName(String port, String country) {
String name = clean(port); String nation = clean(country);
return blank(name + (name.isEmpty() || nation.isEmpty() ? "" : ", ") + nation);
}
private static String normalizeEvent(String value) {
return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT);
}
private static LocalDateTime parseDate(String value) {
if (value == null || value.isBlank()) return null;
String text = value.trim();
try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); }
catch (DateTimeParseException ignored) { }
try { return LocalDateTime.ofInstant(OffsetDateTime.parse(text).toInstant(), ZoneOffset.UTC); }
catch (DateTimeParseException ignored) { }
for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) {
try { return LocalDateTime.parse(text, formatter); }
catch (DateTimeParseException ignored) { }
}
try { return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); }
catch (DateTimeParseException ignored) { }
return null;
}
private static String clean(String value) { return value == null ? "" : value.trim(); }
private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); }
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw new IllegalStateException(exception); }
}
@Override
public ProviderStatus status() {
return ProviderStatus.of("CARRIER_SCHEDULE_ZIM", client.configured(), client.configured());
}
}
@@ -0,0 +1,207 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Semaphore;
@Component
public class ZimTrackingClient {
private static final Duration TOKEN_REFRESH_SKEW = Duration.ofMinutes(1);
@Resource private IntegrationProperties properties;
@Resource private ObjectMapper objectMapper;
private IntegrationProperties.CarrierSchedule.Zim config;
private HttpClient httpClient;
private Semaphore concurrency;
private final Object tokenLock = new Object();
private volatile AccessToken cachedToken;
@PostConstruct
void initialize() {
config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().zim();
httpClient = HttpClient.newBuilder()
.connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout())
.followRedirects(HttpClient.Redirect.NORMAL).build();
concurrency = new Semaphore(config == null ? 1 : config.effectiveMaxConcurrency());
}
public boolean configured() { return config != null && config.configured(); }
public TrackingResponse query(QueryRequest request) {
if (!configured()) manual("ZIM_OFFICIAL_API_NOT_CONFIGURED", "ZIM Tracing API 未启用或缺少授权配置");
if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) {
manual("ZIM_CONTAINER_REQUIRED", "ZIM 首批已验证契约仅支持柜号查询");
}
boolean acquired = false;
try {
concurrency.acquire(); acquired = true;
return execute(normalize(request.trackingNo()), false);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw retryable("ZIM_INTERRUPTED", "ZIM Tracing API 查询被中断", exception);
} finally {
if (acquired) concurrency.release();
}
}
private TrackingResponse execute(String container, boolean refreshedAfterUnauthorized) {
String token = accessToken(false);
HttpResponse<String> response = send(HttpRequest.newBuilder(tracingUri(container))
.timeout(config.effectiveRequestTimeout()).header("Accept", "application/json")
.header("Ocp-Apim-Subscription-Key", config.subscriptionKey())
.header("Authorization", "bearer " + token).GET().build(), "TRACING");
if (response.statusCode() == 401 && !refreshedAfterUnauthorized) {
invalidateToken(token); return execute(container, true);
}
if (response.statusCode() == 401 || response.statusCode() == 403) {
manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 授权或来源环境校验失败");
}
if (response.statusCode() == 429 || response.statusCode() >= 500) {
throw retryable("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 暂时不可用", null);
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 拒绝查询");
}
TracingPayload payload = tracingPayload(response.body());
validateBusinessResult(payload, container);
return new TrackingResponse(payload, response.body());
}
private String accessToken(boolean forceRefresh) {
AccessToken current = cachedToken;
if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value();
synchronized (tokenLock) {
current = cachedToken;
if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value();
String form = "grant_type=client_credentials&client_id=" + encode(config.clientId())
+ "&client_secret=" + encode(config.clientSecret()) + "&scope=tracing";
HttpResponse<String> response = send(HttpRequest.newBuilder(config.authorizationUrl())
.timeout(config.effectiveRequestTimeout()).header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), "AUTH");
if (response.statusCode() == 429 || response.statusCode() >= 500) {
throw retryable("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 服务暂时不可用", null);
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
manual("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 客户端授权失败");
}
JsonNode payload = json(response.body(), "ZIM_AUTH_NON_JSON");
String value = payload.path("access_token").asText("").trim();
if (value.isEmpty()) manual("ZIM_AUTH_TOKEN_MISSING", "ZIM OAuth 响应缺少 access_token");
long expiresIn = Math.max(60L, payload.path("expires_in").asLong(3600L));
cachedToken = new AccessToken(value, Instant.now().plusSeconds(expiresIn));
return value;
}
}
private HttpResponse<String> send(HttpRequest request, String stage) {
try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); }
catch (HttpTimeoutException exception) {
throw retryable("ZIM_" + stage + "_TIMEOUT", "ZIM 官方 API 请求超时", exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw retryable("ZIM_" + stage + "_INTERRUPTED", "ZIM 官方 API 请求被中断", exception);
} catch (IOException exception) {
throw retryable("ZIM_" + stage + "_IO_FAILED", "ZIM 官方 API 网络失败", exception);
}
}
private JsonNode json(String body, String code) {
try { return objectMapper.readTree(body); }
catch (Exception exception) { throw retryable(code, "ZIM 官方 API 返回无效 JSON", exception); }
}
private TracingPayload tracingPayload(String body) {
try { return objectMapper.readValue(body, TracingPayload.class); }
catch (Exception exception) { throw retryable("ZIM_TRACING_NON_JSON", "ZIM Tracing V2 返回无效 JSON", exception); }
}
private void validateBusinessResult(TracingPayload payload, String requestedContainer) {
if (payload == null || payload.bkBlDetails() == null) manual("ZIM_EMPTY_RESULT", "ZIM 返回空业务对象");
List<ContainerDetail> containers = payload.bkBlDetails().safeContainers();
boolean matched = containers.stream().anyMatch(value -> requestedContainer.equals(normalize(value.containerNumber())));
if (containers.isEmpty() || !matched) manual("ZIM_NOT_FOUND", "ZIM 未返回匹配柜号的业务对象");
}
private URI tracingUri(String container) {
return URI.create(config.tracingBaseUrl().toString().replaceAll("/+$", "") + '/' + encode(container));
}
private void invalidateToken(String usedToken) {
synchronized (tokenLock) {
if (cachedToken != null && cachedToken.value().equals(usedToken)) cachedToken = null;
}
}
private static void manual(String code, String message) {
throw new ZimTrackingException("MANUAL_REQUIRED", code, message);
}
private static ZimTrackingException retryable(String code, String message, Throwable cause) {
return new ZimTrackingException("RETRYABLE_FAILURE", code, message, cause);
}
private static String normalize(String value) {
return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT);
}
private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
private record AccessToken(String value, Instant expiresAt) {
boolean usableAt(Instant now) { return expiresAt.minus(TOKEN_REFRESH_SKEW).isAfter(now); }
}
public record TrackingResponse(TracingPayload payload, String rawBody) { }
@JsonIgnoreProperties(ignoreUnknown = true)
public record TracingPayload(FinalEta finalETA, List<RouteDetail> routeDetails, BkBlDetails bkBlDetails,
String bookingReference, String blReference) {
public List<RouteDetail> safeRoutes() { return routeDetails == null ? List.of() : List.copyOf(routeDetails); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record FinalEta(String etaValue, String etaValueDate, String podInd) { }
@JsonIgnoreProperties(ignoreUnknown = true)
public record RouteDetail(String sailingDateTz, String sailingDateDT, String arrivalDateTz,
String arrivalDateDT, String portCode, String port, String country,
String vesselName, String voyage, String leg) { }
@JsonIgnoreProperties(ignoreUnknown = true)
public record BkBlDetails(List<ContainerDetail> consContainerList) {
public List<ContainerDetail> safeContainers() {
return consContainerList == null ? List.of() : List.copyOf(consContainerList);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ContainerDetail(String containerNumber, List<ContainerEvent> containerEventsList) {
public List<ContainerEvent> safeEvents() {
return containerEventsList == null ? List.of() : List.copyOf(containerEventsList);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ContainerEvent(String activityDateTz, String activityDate, String eventName,
String portCode, String port, String country,
String vesselName, String voyage, String leg) { }
public static final class ZimTrackingException extends RuntimeException {
private final String outcome; private final String code;
public ZimTrackingException(String outcome, String code, String message) {
super(message); this.outcome = outcome; this.code = code;
}
public ZimTrackingException(String outcome, String code, String message, Throwable cause) {
super(message, cause); this.outcome = outcome; this.code = code;
}
public String outcome() { return outcome; }
public String code() { return code; }
}
}
@@ -4,6 +4,7 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
import com.baiyue.integration.domain.CarrierScheduleStandardNode;
import com.baiyue.integration.domain.CarrierScheduleExceptionCode;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -24,7 +25,8 @@ public class CarrierScheduleProviderService {
.orElseThrow(() -> new IllegalArgumentException(
"没有支持该船司的轨迹供应商适配器:" + request.carrierCode()))
.query(request);
if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) {
if (result == null || result.observations() == null || result.exceptions() == null
|| !OUTCOMES.contains(result.outcome())) {
throw new IllegalStateException("轨迹供应商返回空结果");
}
if (result.observations().stream().anyMatch(value -> value == null
@@ -34,6 +36,14 @@ public class CarrierScheduleProviderService {
|| !value.rawPayloadHash().matches("[0-9a-f]{64}"))) {
throw new IllegalStateException("轨迹供应商返回了无效标准节点");
}
if (result.exceptions().stream().anyMatch(value -> value == null
|| !CarrierScheduleExceptionCode.supports(value.exceptionCode())
|| value.providerEventId() == null || value.providerEventId().isBlank()
|| value.providerStatus() == null || value.providerStatus().isBlank()
|| value.observedAt() == null || value.rawPayloadHash() == null
|| !value.rawPayloadHash().matches("[0-9a-f]{64}"))) {
throw new IllegalStateException("轨迹供应商返回了无效异常事实");
}
return result;
}
}
@@ -58,6 +58,42 @@ baiyue:
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
region: ${AMAZON_SP_API_REGION:}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
management:
endpoints:
@@ -30,10 +30,11 @@ components:
trackingNo: {type: string, minLength: 1, maxLength: 128}
StandardNode:
type: string
enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]
enum: [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]
ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]}
QueryResult:
type: object
required: [providerCode, outcome, observations]
required: [providerCode, outcome, observations, exceptions]
properties:
providerCode: {type: string}
outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]}
@@ -53,3 +54,18 @@ components:
locationName: {type: string, nullable: true}
sourceReference: {type: string, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
exceptions:
type: array
items:
type: object
required: [providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash]
properties:
providerEventId: {type: string, maxLength: 128}
providerStatus: {type: string, maxLength: 128}
exceptionCode: {$ref: '#/components/schemas/ExceptionCode'}
active: {type: boolean}
observedAt: {type: string, format: date-time}
locationCode: {type: string, nullable: true}
locationName: {type: string, nullable: true}
sourceReference: {type: string, nullable: true}
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
@@ -0,0 +1,74 @@
package com.baiyue.integration.provider;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class EvergreenCarrierScheduleProviderAdapterTest {
private static final String CONTAINER_HTML = """
<html><nav>irrelevant navigation</nav><table><tr><td>other</td></tr></table>
<table class="tracking"><tr>
<th>Method</th><th>地点</th><th>箱号</th><th>货柜动态</th><th>日期</th><th>柜型</th><th>VGM</th><th>船名航次</th>
</tr><tr>
<td>CY</td><td>NINGBO, CHINA (CN)</td><td> EGSU9319340 </td><td>Loaded (FCL) on vessel</td>
<td>AUG-07-2026</td><td>40HQ</td><td></td><td>EVER LOVELY&nbsp;1232-067E</td>
</tr><tr>
<td>CY</td><td>NINGBO, CHINA (CN)</td><td>EITU1262610</td><td>Loaded (FCL) on vessel</td>
<td>AUG-06-2026</td><td>40HQ</td><td></td><td>EVER LOVELY 1232-067E</td>
</tr></table></html>
""";
private static final String BILL_HTML = """
<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>
""";
@Test
void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() {
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000",
CONTAINER_HTML + BILL_HTML);
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30);
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
.observations("egsu9319340", response, queriedAt);
assertThat(values).hasSize(2);
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> {
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0));
assertThat(value.actualAt()).isNotEqualTo(queriedAt);
assertThat(value.locationCode()).isEqualTo("CN");
assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)");
assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai")
.doesNotContain("000000000000");
});
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement()
.satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)));
}
@Test
void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() {
var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "<html><table></table></html>",
"0000", CONTAINER_HTML);
var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class))
.observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30));
assertThat(values).singleElement().satisfies(value -> {
assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0));
assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL");
});
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel"))
.isEqualTo("LOADED_ON_VESSEL");
assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull();
}
@Test
void missingStableHeaderOrExactContainerRowIsNotParsed() {
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(
"<table><tr><th>箱号</th><th>日期</th></tr><tr><td>EGSU9319340</td><td>AUG-07-2026</td></tr></table>",
"EGSU9319340")).isNull();
assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull();
}
}
@@ -0,0 +1,91 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EvergreenTrackingClientTest {
private HttpServer server;
@AfterEach
void stopServer() { if (server != null) server.stop(0); }
@Test
void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception {
List<String> forms = new ArrayList<>();
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> {
String form = body(exchange); forms.add(form);
String html = form.startsWith("TYPE=CNTR")
? "<html><input value='000000000000' name='bl_no'><table></table></html>"
: "<html><table><tr><td>预计装船日</td><td>AUG-07-2026</td></tr></table></html>";
respond(exchange, 200, html);
});
server.start();
var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"));
assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000");
assertThat(result.billSuffix()).isEqualTo("0000");
assertThat(result.toString()).doesNotContain("000000000000");
}
@Test
void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() {
assertThat(EvergreenTrackingClient.relatedBill(
"<input VALUE=\"000000000000\" NAME=\"bl_no\">" )).isEqualTo("000000000000");
assertThat(EvergreenTrackingClient.relatedBill(
"<a href=\"javascript:GetDispInfo('000000000000')\">detail</a>" )).isEqualTo("000000000000");
}
@Test
void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML"));
server.stop(0);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200, "<html><table></table></html>"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")))
.isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class,
value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING"));
}
private EvergreenTrackingClient client() {
var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true,
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
EvergreenTrackingClient client = new EvergreenTrackingClient();
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen)));
client.initialize(); return client;
}
private static String body(HttpExchange exchange) throws IOException {
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
}
}
@@ -0,0 +1,95 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HedeCarrierScheduleProviderAdapterTest {
@Test
void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception {
String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00",
"", "", "", "", "", "");
var rows = HedeCarrierScheduleProviderAdapter.rows(html);
HedeTrackingClient client = mock(HedeTrackingClient.class);
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
assertThat(rows).singleElement().satisfies(value -> {
assertThat(value.containerNo()).isEqualTo("XYLU8251457");
assertThat(value.vessel()).isEqualTo("POSEN");
assertThat(value.voyage()).isEqualTo("2607W");
assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00");
assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00");
});
assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT");
assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> {
assertThat(value.estimatedAt()).isNotNull();
assertThat(value.actualAt()).isNull();
});
}
@Test
void mapsOnlyActualHedeMilestonesForTheRequestedContainer() {
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", "");
HedeTrackingClient client = mock(HedeTrackingClient.class);
when(client.configured()).thenReturn(true);
when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")))
.thenReturn(new TrackingResponse(html));
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"));
assertThat(result.outcome()).isEqualTo("SUCCESS");
assertThat(result.observations()).extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT",
"LOADED_ON_VESSEL");
assertThat(result.observations()).allSatisfy(value -> {
assertThat(value.providerEventId()).hasSize(64);
assertThat(value.rawPayloadHash()).hasSize(64);
assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W");
});
assertThat(result.observations()).extracting(value -> value.observedAt())
.containsOnly(result.observations().get(0).observedAt());
}
@Test
void actualEmptyReturnIsMappedPrecisely() throws Exception {
String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00",
"2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "",
"2026-08-28 08:00");
HedeTrackingClient client = mock(HedeTrackingClient.class);
var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html);
assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode()))
.singleElement().satisfies(value -> {
assertThat(value.actualAt()).isNotNull();
assertThat(value.estimatedAt()).isNull();
});
}
@Test
void refusesAmbiguousBillLevelAutomation() {
HedeTrackingClient client = mock(HedeTrackingClient.class);
HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client);
var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123"));
assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE");
assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED");
}
private static String row(String container, String etd, String eta, String emptyOut, String fullIn,
String loaded, String discharged, String fullOut, String emptyReturned) {
return "<table><tbody><tr class=\"read-tr\">"
+ td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta)
+ td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned)
+ td("查看") + "</tr></tbody></table>";
}
private static String td(String value) { return "<td>" + value + "</td>"; }
}
@@ -0,0 +1,59 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class HedeTrackingClientTest {
private HttpServer server;
@AfterEach
void stopServer() { if (server != null) server.stop(0); }
@Test
void postsTheConfirmedHedeFormAsUtf8() throws Exception {
AtomicReference<String> postedBody = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/getVBilldynamic", exchange -> {
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
respond(exchange, 200, "<html><table><tr class=\"read-tr\"><td>***</td>"
+ "<td>XYLU8251457</td></tr></table></html>");
});
server.start();
var hede = new IntegrationProperties.CarrierSchedule.Hede(true,
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"),
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
HedeTrackingClient client = new HedeTrackingClient();
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
new IntegrationProperties.CarrierSchedule(null, hede, null, null, null)));
client.initialize();
var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457"));
assertThat(response.html()).contains("XYLU8251457");
assertThat(postedBody.get()).isEqualTo(
"billno=&cntr=XYLU8251457&selecttype=1&resultlist=1&paramtype=0");
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -0,0 +1,109 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MatsonCarrierScheduleProviderAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception {
String raw = """
{"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7",
"originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL",
"loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[
{"date":%d,"location":"LONG BEACH","status":"INGATE"},
{"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]}
""".formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(),
Instant.parse("2026-08-18T22:12:00Z").toEpochMilli());
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
when(client.configured()).thenReturn(true);
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")))
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
var result = new MatsonCarrierScheduleProviderAdapter(client)
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"));
assertThat(result.observations()).extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2);
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement();
assertThat(result.observations()).extracting(value -> value.observedAt())
.containsOnly(result.observations().get(0).observedAt());
assertThat(result.exceptions()).isEmpty();
}
@Test
void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception {
String raw = """
{"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6",
"originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX",
"vesselETA":"08/24/26",
"loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[
{"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"},
{"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"},
{"date":1786940100000,"location":"NINGBO","status":"INGATE"}]}
""";
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
when(client.configured()).thenReturn(true);
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")))
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client);
var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
assertThat(result.outcome()).isEqualTo("SUCCESS");
assertThat(result.observations()).extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT");
assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode()))
.singleElement().satisfies(value -> {
assertThat(value.estimatedAt()).isNotNull();
assertThat(value.actualAt()).isNull();
});
assertThat(result.exceptions()).singleElement().satisfies(value -> {
assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD");
assertThat(value.active()).isTrue();
assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance");
});
}
@Test
void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() {
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL"))
.isEqualTo("LOADED_ON_VESSEL");
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL"))
.isEqualTo("DISCHARGED_FROM_VESSEL");
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP"))
.isEqualTo("PICKUP_READY");
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP");
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED"))
.isEqualTo("EMPTY_RETURNED");
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT"))
.isNull();
assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance"))
.isNull();
assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode(
"CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD");
}
@Test
void emptyBusinessObjectReturnsNoFacts() throws Exception {
String raw = "{}";
MatsonTrackingClient client = mock(MatsonTrackingClient.class);
when(client.configured()).thenReturn(true);
when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")))
.thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw));
var result = new MatsonCarrierScheduleProviderAdapter(client)
.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"));
assertThat(result.outcome()).isEqualTo("NO_CHANGE");
assertThat(result.observations()).isEmpty();
assertThat(result.exceptions()).isEmpty();
}
}
@@ -0,0 +1,73 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class MatsonTrackingClientTest {
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) server.stop(0);
}
@Test
void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception {
AtomicReference<String> postedBody = new AtomicReference<>();
AtomicReference<String> postedCookie = new AtomicReference<>();
AtomicReference<String> requestedWith = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/page", exchange -> {
exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/");
respond(exchange, 200, "<input type=\"hidden\" id=\"ttr\" name=\"ttr\" value=\"dynamic123\" />");
});
server.createContext("/query", exchange -> {
postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie"));
requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With"));
respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}");
});
server.start();
int port = server.getAddress().getPort();
var matson = new IntegrationProperties.CarrierSchedule.Matson(true,
URI.create("http://127.0.0.1:" + port + "/page"),
URI.create("http://127.0.0.1:" + port + "/query"),
Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO);
MatsonTrackingClient client = new MatsonTrackingClient();
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
new IntegrationProperties.CarrierSchedule(matson, null, null, null, null)));
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
client.initialize();
var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"));
assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895");
assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956")
.contains("trackingVal=tckconta").contains("ttr=dynamic123");
assertThat(postedCookie.get()).contains("matson-session=abc123");
assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest");
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class SmlCarrierScheduleProviderAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception {
String timeline = """
{"TRANS_RESULT_KEY":"S","count":9,"events":[
{"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"},
{"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"},
{"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"},
{"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"},
{"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"},
{"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"},
{"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"},
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
{"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]}
""";
SmlTrackingClient client = mock(SmlTrackingClient.class);
when(client.configured()).thenReturn(true);
var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1",
objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response);
var result = new SmlCarrierScheduleProviderAdapter(client)
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
assertThat(result.observations()).hasSize(7);
assertThat(result.observations()).filteredOn(value -> value.actualAt() != null)
.extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4);
assertThat(result.observations()).extracting(value -> value.observedAt())
.containsOnly(result.observations().get(0).observedAt());
assertThat(result.observations()).extracting(value -> value.providerNodeCode())
.doesNotContain("FITMDO", "MOTYDO");
}
@Test
void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception {
String timeline = """
{"events":[
{"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"},
{"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]}
""";
var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline),
objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline);
var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class))
.observations("SMCU7019890", response);
assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement()
.satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED"));
assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement();
}
@Test
void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() {
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull();
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull();
assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD"))
.isEqualTo("DISCHARGED_FROM_VESSEL");
}
}
@@ -0,0 +1,147 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SmlTrackingClientTest {
private HttpServer server;
@AfterEach
void stopServer() { if (server != null) server.stop(0); }
@Test
void executesTheConfirmed122125124126Chain() throws Exception {
List<String> forms = new ArrayList<>();
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> {
String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
forms.add(form);
String body = form.contains("f_cmd=122")
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}"
: form.contains("f_cmd=125")
? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}"
: "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}";
respond(exchange, 200, body);
});
server.start();
var result = new SmlCarrierScheduleProviderAdapter(client())
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"));
assertThat(result.outcome()).isEqualTo("SUCCESS");
assertThat(result.observations()).singleElement()
.satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1"));
assertThat(forms).hasSize(4);
assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890");
assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1");
assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1");
assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890");
}
@Test
void nonJsonAndMissingBusinessKeysFailClosed() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200, "<html>schema changed</html>"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
value -> assertThat(value.code()).contains("NON_JSON"));
server.stop(0);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200,
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING"));
}
@Test
void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200,
"{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED"));
server.stop(0);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 200,
"{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class,
value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND"));
}
@Test
void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> respond(exchange, 503, "{}"));
server.start();
assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503");
assertThat(value.retryable()).isTrue();
});
server.stop(0);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/track", exchange -> {
try { Thread.sleep(200); }
catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
respond(exchange, 200, "{}");
});
server.start();
assertThatThrownBy(() -> client(Duration.ofMillis(50))
.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")))
.isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> {
assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT");
assertThat(value.retryable()).isTrue();
});
}
private SmlTrackingClient client() {
return client(Duration.ofSeconds(2));
}
private SmlTrackingClient client(Duration requestTimeout) {
var sml = new IntegrationProperties.CarrierSchedule.Sml(true,
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"),
Duration.ofSeconds(2), requestTimeout, Duration.ZERO);
SmlTrackingClient client = new SmlTrackingClient();
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
new IntegrationProperties.CarrierSchedule(null, null, sml, null, null)));
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper());
client.initialize();
return client;
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
@@ -0,0 +1,106 @@
package com.baiyue.integration.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class ZimCarrierScheduleProviderAdapterTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception {
String fixture = """
{"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ",
"finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"},
"routeDetails":[
{"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"},
{"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"},
{"portCode":"USNYC","port":"New York","country":"US"}],
"bkBlDetails":{"consContainerList":[
{"containerNumber":"SIBLING000001 ","containerEventsList":[
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]},
{"containerNumber":" zcsu7881970 ","containerEventsList":[
{"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"},
{"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
{"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
{"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
{"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"},
{"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}}
""";
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0);
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
.observations("ZCSU7881970", response, queriedAt);
assertThat(values).hasSize(7);
assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt);
assertThat(values).filteredOn(value -> value.actualAt() != null)
.extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL");
assertThat(values).filteredOn(value -> value.estimatedAt() != null)
.extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT",
"ARRIVED_DESTINATION_PORT");
assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt()));
assertThat(values).extracting(value -> value.providerNodeCode())
.doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT");
assertThat(values).allSatisfy(value -> assertThat(value.sourceReference())
.isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001"));
}
@Test
void eventNameMappingIsAnExactExpandableWhitelist() {
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT");
assertThat(ZimCarrierScheduleProviderAdapter.standardNode(
"Empty container dispatched from Port of Loading to Customer")).isNull();
assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull();
}
@Test
void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception {
String fixture = """
{"bkBlDetails":{"consContainerList":[
{"containerNumber":" ZCSU7205614 ","containerEventsList":[
{"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"},
{"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"},
{"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
{"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"},
{"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]},
{"containerNumber":"ZCSU7960370","containerEventsList":[
{"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}}
""";
var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class);
var response = new ZimTrackingClient.TrackingResponse(payload, fixture);
var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class))
.observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0));
assertThat(payload.routeDetails()).isNull();
assertThat(payload.finalETA()).isNull();
assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull());
assertThat(values).extracting(value -> value.standardNode())
.containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT");
assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY");
assertThat(values).extracting(value -> value.providerNodeCode())
.doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER");
}
}
@@ -0,0 +1,152 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ZimTrackingClientTest {
private HttpServer server;
@AfterEach
void stopServer() { if (server != null) server.stop(0); }
@Test
void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception {
Credentials credentials = Credentials.random();
String accessToken = UUID.randomUUID().toString();
AtomicInteger authCalls = new AtomicInteger();
AtomicReference<String> authForm = new AtomicReference<>();
AtomicReference<String> tracingPath = new AtomicReference<>();
server = server();
server.createContext("/authorize/v1", exchange -> {
authCalls.incrementAndGet(); authForm.set(body(exchange));
respond(exchange, 200, tokenBody(accessToken));
});
server.createContext("/tracing/v2", exchange -> {
tracingPath.set(exchange.getRequestURI().getPath());
assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken);
assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key"))
.isEqualTo(credentials.subscriptionKey());
respond(exchange, 200, successBody());
});
server.start();
ZimTrackingClient client = client(credentials);
client.query(query()); client.query(query());
assertThat(authCalls).hasValue(1);
assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing")
.contains("client_id=", "client_secret=");
assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970");
}
@Test
void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception {
Credentials credentials = Credentials.random();
String firstToken = UUID.randomUUID().toString();
String secondToken = UUID.randomUUID().toString();
AtomicInteger authCalls = new AtomicInteger();
AtomicInteger tracingCalls = new AtomicInteger();
server = server();
server.createContext("/authorize/v1", exchange -> {
int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken));
});
server.createContext("/tracing/v2", exchange -> {
int call = tracingCalls.incrementAndGet();
respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody());
});
server.start();
client(credentials).query(query());
assertThat(authCalls).hasValue(2);
assertThat(tracingCalls).hasValue(2);
}
@Test
void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception {
Credentials credentials = Credentials.random();
server = server();
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "<html>forbidden</html>"));
server.start();
assertThatThrownBy(() -> client(credentials).query(query()))
.isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> {
assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED");
assertThat(value.code()).isEqualTo("ZIM_HTTP_403");
assertThat(value.getMessage()).doesNotContain(credentials.clientSecret())
.doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND");
});
}
@Test
void emptyOrMissingContainerListRequiresManualHandling() throws Exception {
Credentials credentials = Credentials.random();
AtomicReference<String> businessBody = new AtomicReference<>(
"{\"bkBlDetails\":{\"consContainerList\":[]}}");
server = server();
server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString())));
server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get()));
server.start();
ZimTrackingClient client = client(credentials);
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND"));
businessBody.set("{}");
assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying(
ZimTrackingClient.ZimTrackingException.class,
value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT"));
}
private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); }
private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); }
private ZimTrackingClient client(Credentials credentials) {
String root = "http://127.0.0.1:" + server.getAddress().getPort();
var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"),
URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(),
credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10);
ZimTrackingClient client = new ZimTrackingClient();
ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null,
new IntegrationProperties.CarrierSchedule(null, null, null, zim, null)));
ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client;
}
private static String body(HttpExchange exchange) throws IOException {
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
private static String tokenBody(String token) {
return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}";
}
private static String successBody() {
return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}";
}
private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
}
private record Credentials(String clientId, String clientSecret, String subscriptionKey) {
static Credentials random() {
return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString());
}
}
}
@@ -3,6 +3,7 @@ package com.baiyue.integration.service;
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation;
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
import org.junit.jupiter.api.Test;
@@ -21,6 +22,7 @@ class CarrierScheduleProviderServiceTest {
assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED");
assertThat(result.observations()).isEmpty();
assertThat(result.exceptions()).isEmpty();
}
@Test
@@ -30,7 +32,7 @@ class CarrierScheduleProviderServiceTest {
@Override public QueryResult query(QueryRequest request) {
return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN",
LocalDateTime.now(), null, null, null, null, null,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null,
LocalDateTime.now());
}
};
@@ -38,4 +40,20 @@ class CarrierScheduleProviderServiceTest {
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
.hasMessageContaining("无效标准节点");
}
@Test
void rejectsProviderHoldsOutsideTheFourFrozenCodes() {
CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() {
@Override public boolean supports(String carrierCode) { return true; }
@Override public QueryResult query(QueryRequest request) {
return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation(
"hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
LocalDateTime.now());
}
};
var service = new CarrierScheduleProviderService(List.of(invalid));
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
.hasMessageContaining("无效异常事实");
}
}
@@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest {
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
50, Duration.ZERO, 0);
return new IntegrationProperties(baidu, null);
return new IntegrationProperties(baidu, null, null);
}
@Configuration
@@ -87,11 +87,11 @@ async function createManualSchedule(context, suffix, oceanBookingId, customerId)
}
export async function run(context) {
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询',
'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定',
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询',
'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键',
'external-third-party')
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询',
'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功',
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功',
'external-third-party')
const suffix = context.marker.replace(/\D/g, '').slice(-10)
@@ -100,8 +100,10 @@ export async function run(context) {
const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item]))
context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse,
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
&& byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true
&& byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true
&& byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true
&& byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true
&& byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
@@ -130,29 +132,7 @@ export async function run(context) {
id(data(replay)?.query?.query?.id) === queryId,
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
let version = Number(created.query.query.version)
for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) {
const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: `第${attemptNo}次官网查询` },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) })
const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS)
context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response,
detail?.query?.query?.status === 'MANUAL_REQUIRED'
&& Number(detail?.query?.query?.attemptCount) === attemptNo
&& detail?.attempts?.length === attemptNo
&& detail?.attempts?.at(-1)?.coverageGap === true
&& detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED'
&& Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason),
`status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS)
version = Number(detail?.query?.query?.version)
}
const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: version, reason: '超过窗口上限' },
{ 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) })
step(context, '同一3天窗口第4次查询被服务端拒绝', fourth,
fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)),
`HTTP=${fourth.http}, code=${codeOf(fourth)}`)
const version = Number(created.query.query.version)
const unknownCarrier = await request(context, 'POST', BASE,
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` },
@@ -175,25 +155,17 @@ export async function run(context) {
{ 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) })
const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS)
context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual,
completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
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?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS)
const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`,
{ expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' },
{ 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) })
step(context, '成功后未到3天禁止再次查询', early,
early.http === 200 && ['A0409', '409'].includes(codeOf(early)),
`HTTP=${early.http}, code=${codeOf(early)}`)
const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`)
const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS)
context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse,
detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED')
context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse,
!Object.hasOwn(detail || {}, 'attempts')
&& detail?.histories?.some((item) => item.action === 'CREATE')
&& detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3
&& detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
`attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
`attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
}
@@ -4,7 +4,7 @@ import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
@@ -14,9 +14,10 @@ function contextFor(routes) {
const calls = []
return {
calls,
request: async (method, requestPath) => {
calls.push({ method, path: requestPath })
assert.equal(method, 'GET')
marker: 'T-PHASE4', token: 'token',
request: async (method, requestPath, body, _token, headers = {}) => {
calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath })
assert.ok(['GET', 'POST'].includes(method))
assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`)
return response(routes[requestPath])
},
@@ -35,16 +36,22 @@ const task = {
id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001',
oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO',
trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED',
latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00',
latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5,
lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00',
expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止',
expiresAt: null, stoppedAt: null, stopReason: null,
}
const node = {
id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY',
nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP',
nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null,
actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles',
sourceReference: null, createdAt: '2026-08-20T12:00:01',
sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01',
}
const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002',
providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) }
const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1',
providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true,
observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach',
sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' }
const history = {
id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5,
reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00',
@@ -54,10 +61,12 @@ const carriers = [{
accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false,
}]
test('readonly module statically forbids writes and internal execution endpoints', () => {
test('module only permits the documented manual-node write and forbids internal execution endpoints', () => {
assert.match(source, /context\.request\('GET'/)
assert.doesNotMatch(source, /\bfetch\s*\(/)
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/)
assert.match(source, /'Idempotency-Key'/)
for (const method of ['PUT', 'PATCH', 'DELETE']) {
assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`))
}
for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) {
@@ -65,12 +74,14 @@ test('readonly module statically forbids writes and internal execution endpoints
}
})
test('canonical task and node sets are locked to the phase-4 read contract', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
test('canonical task node and exception sets are locked to the phase-4 contract', () => {
assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED'])
assert.deepEqual(STANDARD_NODES, [
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
'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',
])
assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
})
test('empty page validates read-only pagination without detail calls', async () => {
@@ -90,10 +101,12 @@ test('existing task reads page, detail and history with nullable node times', as
const context = contextFor({
[`${base}/carriers`]: carriers,
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] },
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
[`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] },
[`${base}/${task.id}/history`]: [history],
})
await run(context)
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET'])
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET'])
assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`)
assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`)
})
@@ -2,11 +2,13 @@ const MODULE = 'phase4-carrier-nodes'
const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/carrier-schedule-queries'
export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED'])
export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'STOPPED'])
export const STANDARD_NODES = Object.freeze([
'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT',
'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY',
'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',
])
export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
function id(value) { return value == null ? '' : String(value) }
function number(value) { return Number.isFinite(Number(value)) }
@@ -18,21 +20,32 @@ function validTask(task) {
return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId))
&& QUERY_STATUSES.includes(task?.status)
&& nullableId(task?.containerBookingId)
&& nullableText(task?.latestStandardNode)
&& (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode))
&& ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
&& nullableText(task?.latestActualNode)
&& (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode))
&& number(task?.version)
&& ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt']
.every((field) => nullableIso(task?.[field]))
&& nullableText(task?.stopReason)
}
function validNode(node) {
return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode)
return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode)
&& STANDARD_NODES.includes(node?.standardNode)
&& number(node?.nodeSequence) && Number(node.nodeSequence) > 0
&& nullableIso(node?.observedAt) && node?.observedAt != null
&& nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt)
&& nullableText(node?.locationCode) && nullableText(node?.locationName)
&& nullableText(node?.sourceReference) && nullableIso(node?.createdAt)
&& typeof node?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(node.rawPayloadHash)
}
function validException(value) {
return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus)
&& EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean'
&& nullableIso(value?.observedAt) && value?.observedAt != null
&& nullableText(value?.locationCode) && nullableText(value?.locationName)
&& nullableText(value?.sourceReference) && nullableIso(value?.createdAt)
&& typeof value?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(value.rawPayloadHash)
}
function validHistory(item) {
@@ -71,12 +84,33 @@ export async function run(context) {
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS)
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
const histories = Array.isArray(detail?.histories) ? detail.histories : []
context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse,
validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean'
&& Array.isArray(detail?.nodes) && nodes.every(validNode)
&& Array.isArray(detail?.exceptions) && exceptions.every(validException)
&& Array.isArray(detail?.histories) && histories.every(validHistory),
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS)
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS)
const task = detail.query.query
const observedAt = task.latestActualAt || '2026-08-21T00:00:00'
const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, {
expectedVersion: task.version,
standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE',
observedAt,
estimatedAt: observedAt,
actualAt: null,
locationCode: null,
locationName: null,
reason: '四期船节点人工预计事实验收',
}, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) })
const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS)
context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse,
QUERY_STATUSES.includes(manualDetail?.query?.query?.status)
&& manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL'
&& item?.estimatedAt != null && item?.actualAt == null),
`status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)