发布已通过 TEST 回归的 2026-10-08 批次至灰度 #24

Merged
admin merged 13 commits from test into main 2026-10-08 22:15:43 +08:00
160 changed files with 5704 additions and 4799 deletions
Showing only changes of commit 16d2d9111a - Show all commits
@@ -20,6 +20,7 @@ public class JwksJwtDecoderAutoConfiguration {
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri,
@Value("${baiyue.security.issuer:auth-service}") String issuer,
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
// Fetch and cache JWKS on token verification, so startup does not depend on Auth availability.
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
@@ -0,0 +1,94 @@
package com.baiyue.common.security;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
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.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JwksJwtDecoderAutoConfigurationTest {
@Test
void createsDecoderWhenAuthIsUnavailableButRejectsTokenVerification() throws Exception {
int port;
try (var socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
"http://127.0.0.1:" + port + "/jwks", "auth-service", "lastleg-api");
var key = new RSAKeyGenerator(2048).keyID("current").generate();
String token = token(key, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
assertThatThrownBy(() -> decoder.decode(token)).isInstanceOf(JwtException.class);
}
@Test
void fetchesOnFirstVerificationCachesKeysAndRefreshesForRotation() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").generate();
var next = new RSAKeyGenerator(2048).keyID("next").generate();
var published = new AtomicReference<>(new JWKSet(current.toPublicJWK()));
var requests = new AtomicInteger();
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/jwks", exchange -> {
requests.incrementAndGet();
byte[] body = published.get().toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
try (var output = exchange.getResponseBody()) {
output.write(body);
}
});
server.start();
try {
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
"http://127.0.0.1:" + server.getAddress().getPort() + "/jwks",
"auth-service", "lastleg-api");
assertThat(requests.get()).isZero();
String valid = token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
assertThat(decoder.decode(valid).getSubject()).isEqualTo("42");
assertThatCode(() -> decoder.decode(valid)).doesNotThrowAnyException();
assertThat(requests.get()).isEqualTo(1);
for (String invalid : new String[]{
token(current, "other", "lastleg-api", 120, JWSAlgorithm.RS256),
token(current, "auth-service", "other", 120, JWSAlgorithm.RS256),
token(current, "auth-service", "lastleg-api", -120, JWSAlgorithm.RS256),
token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS512)}) {
assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class);
}
published.set(new JWKSet(next.toPublicJWK()));
assertThat(decoder.decode(token(next, "auth-service", "lastleg-api", 120,
JWSAlgorithm.RS256)).getSubject()).isEqualTo("42");
assertThat(requests.get()).isEqualTo(2);
} finally {
server.stop(0);
}
}
private String token(RSAKey key, String issuer, String audience, int ttl, JWSAlgorithm algorithm)
throws Exception {
var jwt = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(key.getKeyID()).build(),
new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience)
.issueTime(Date.from(Instant.now().minusSeconds(300)))
.expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build());
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
}
@@ -6327,7 +6327,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107004,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107005,0,CURRENT_TIMESTAMP(3));
@@ -6383,8 +6383,10 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107190,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-inventory:list','退货库存管理','/overseas-warehouse/return-inventory','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107191,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:return-cartons:list','退货箱号管理','/overseas-warehouse/return-cartons','GET',891,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6755,8 +6757,10 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107121,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107122,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107123,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107130,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107190,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107044,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107200,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107201,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107202,0,CURRENT_TIMESTAMP(3));
+645 -1
View File
@@ -13,6 +13,7 @@ USE baiyue_lastleg;
CREATE TABLE ll_cargo_discard_application (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认完成时间',
discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照',
@@ -68,6 +69,7 @@ CREATE TABLE ll_cargo_discard_item (
product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称',
available_packages_snapshot int NOT NULL COMMENT '可用箱数快照',
discard_packages int NOT NULL COMMENT '申请废弃箱数',
completion_photo_file_ids text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA完工照片文件ID JSON数组',
actual_discard_packages int DEFAULT NULL COMMENT 'PDA确认实际废弃箱数',
pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人',
@@ -2301,7 +2303,7 @@ CREATE TABLE ll_relabel_status_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史';
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
CREATE TABLE ll_return_inventory_outbound_movement (
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
id BIGINT NOT NULL COMMENT '退货出库SKU流水ID',
outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键',
@@ -3541,3 +3543,645 @@ CREATE TABLE event_delivery_attempt (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables;
-- 2026-10-08 退货合箱精确预占、来源追溯与箱级上架;MySQL 8,可重复执行。
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='uk_ll_return_inventory_carton_receipt'), 'ALTER TABLE ll_return_inventory_carton DROP INDEX uk_ll_return_inventory_carton_receipt', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
USE baiyue_lastleg;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_type VARCHAR(16) NULL COMMENT ''签收类型 PALLET/BOX/PARCEL'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN unit_code VARCHAR(128) NULL COMMENT ''签收单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='total_box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN total_box_count BIGINT NULL COMMENT ''签收SKU箱数汇总'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='width'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='height'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''实测尺寸cm'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='weight'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''实测重量kg'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt_item' AND column_name='box_count'), 'SELECT 1', 'ALTER TABLE ll_return_receipt_item ADD COLUMN box_count BIGINT NULL COMMENT ''SKU签收箱数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_code VARCHAR(128) NULL COMMENT ''来源单元业务码'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
CREATE TABLE IF NOT EXISTS ll_return_packing_plan (
id BIGINT NOT NULL COMMENT '合箱计划ID', plan_no VARCHAR(128) NOT NULL COMMENT '计划业务号',
customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
return_nos_json JSON NOT NULL COMMENT '来源退货单范围', boxes_json JSON NOT NULL COMMENT '目标箱明细',
mode VARCHAR(32) NOT NULL COMMENT 'SKU散件或BOX箱合并', status VARCHAR(32) NOT NULL COMMENT 'PENDING_REVIEW待审 READY已推送 COMPLETED完成 CANCELLED取消',
remark VARCHAR(1000) NULL COMMENT '合箱备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
pushed_at DATETIME(3) NULL COMMENT '审核推送时间', completed_at DATETIME(3) NULL COMMENT '作业完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_packing_plan_no(plan_no),
UNIQUE KEY uk_ll_return_packing_plan_key(customer_id,idempotency_key),
KEY idx_ll_return_packing_plan_warehouse(overseas_warehouse_id,status,pushed_at)
) ENGINE=InnoDB COMMENT='退货合箱计划';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton_item' AND column_name='packing_reserved_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton_item ADD COLUMN packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''合箱计划预占件数'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='source_items_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN source_items_json JSON NULL COMMENT ''精确来源箱和SKU锁定数量'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_marks_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_marks_json JSON NULL COMMENT ''完成生成或并入的目标箱唛'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='request_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN request_hash VARCHAR(64) NULL COMMENT ''创建载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='completion_hash'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN completion_hash VARCHAR(64) NULL COMMENT ''完成载荷摘要'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='location_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN location_code VARCHAR(128) NULL COMMENT ''退货箱当前库位'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='putaway_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN putaway_at DATETIME(3) NULL COMMENT ''退货箱上架时间'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_unit_type'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_unit_type VARCHAR(32) NULL COMMENT ''PALLET或BOX实物箱 PARCEL散SKU'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'));
CREATE TABLE IF NOT EXISTS ll_return_packing_trace (
id BIGINT NOT NULL COMMENT '合箱追溯ID', customer_id VARCHAR(128) NOT NULL COMMENT '原客户隔离键',
operation_id BIGINT NOT NULL COMMENT '合箱操作ID', source_carton_mark VARCHAR(128) NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照',
target_carton_mark VARCHAR(128) NOT NULL COMMENT '目标箱唛', return_no VARCHAR(128) NOT NULL COMMENT '原始退货单号',
sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码', quantity BIGINT NOT NULL COMMENT '本次移动件数',
created_at DATETIME(3) NOT NULL COMMENT '发生时间', PRIMARY KEY(id),
KEY idx_return_packing_trace_order(customer_id,return_no,created_at),
KEY idx_return_packing_trace_target(customer_id,target_carton_mark,sku_code),
KEY idx_return_packing_trace_source(customer_id,source_carton_mark,sku_code),
CONSTRAINT chk_return_packing_trace_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货合箱逐SKU原始退货单不可变追溯';
ALTER TABLE ll_return_packing_trace MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT '商品SKU编码';
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_operation' AND column_name='result_json'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_operation ADD COLUMN result_json JSON NULL COMMENT ''幂等操作不可变返回快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
SET @return_packing_ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_packing_plan' AND column_name='result_labels_json'), 'SELECT 1', 'ALTER TABLE ll_return_packing_plan ADD COLUMN result_labels_json JSON NULL COMMENT ''计划完工标签快照'''));
PREPARE return_packing_stmt FROM @return_packing_ddl; EXECUTE return_packing_stmt; DEALLOCATE PREPARE return_packing_stmt;
-- Existing outbound tables need this field even when created before the fee snapshot baseline.
USE baiyue_lastleg;
SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='fee_coverage_json'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN fee_coverage_json JSON NULL COMMENT ''发运原费用及服务覆盖确认快照''');
PREPARE return_outbound_stmt FROM @return_outbound_ddl;
EXECUTE return_outbound_stmt;
DEALLOCATE PREPARE return_outbound_stmt;
-- 原生退货换标排车:保留独立退货箱来源,不伪造普通库存仓位。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick ADD COLUMN return_carton_id BIGINT NULL COMMENT ''独立退货库存箱ID,与普通仓位互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID';
ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥';
-- 真实退回凭据与尾程订单客户键使用同一排序规则,避免相关库存子查询冲突。
SET @return_customer_collation = (SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id');
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''客户数据归属''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = CONCAT('ALTER TABLE ll_return_stock_receipt MODIFY COLUMN source_outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE ', @return_customer_collation, ' NOT NULL COMMENT ''原发运出库业务编号''');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_carton_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_carton_id BIGINT NULL COMMENT ''原生退货箱来源ID,与实收来源互斥''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND constraint_name='chk_dispatch_pick_receipt_source')>0,
'ALTER TABLE ll_dispatch_pda_pick_order DROP CHECK chk_dispatch_pick_receipt_source', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pda_pick_order ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
ALTER TABLE ll_dispatch_pda_pick_order ADD CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1);
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='signed_packages')=0,
'ALTER TABLE ll_delivery_order ADD COLUMN signed_packages INT NULL COMMENT ''保存签收资料时冻结的实际签收箱数,不含此前已退回量''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
CREATE TABLE IF NOT EXISTS event_delivery_attempt (
attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键',
service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务',
event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型',
entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键',
event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空',
target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务',
started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间',
finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间',
duration_ms BIGINT NULL COMMENT '尝试耗时毫秒',
outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED',
error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌',
next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间',
PRIMARY KEY (attempt_id),
KEY idx_delivery_attempt_entry (service_name,entry_id,started_at),
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
-- 补齐排车实体引用的真实退回来源字段;原生退货箱单独使用 return_carton_id。
USE baiyue_lastleg;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_item ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
SET @return_dispatch_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='return_receipt_id')=0,
'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN return_receipt_id BIGINT NULL COMMENT ''明确选用的真实退回入仓来源''', 'SELECT 1');
PREPARE return_dispatch_stmt FROM @return_dispatch_ddl;
EXECUTE return_dispatch_stmt;
DEALLOCATE PREPARE return_dispatch_stmt;
-- 退货换标排车使用正式车次域,补齐旧本地库缺少的车队结构。
-- MySQL 8;显式 baiyue_lastleg;不写业务数据,可重复执行。
USE baiyue_lastleg;
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted, created_at, updated_at)
VALUES
(5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)),
(5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-master-data:driver:list',
'platform:overseas-master-data:driver:create',
'platform:overseas-master-data:driver:edit',
'platform:overseas-master-data:driver:upload',
'platform:overseas-master-data:driver:review',
'platform:fleet:trip:list',
'platform:fleet:trip:create',
'platform:fleet:trip:assign',
'platform:fleet:trip:edit',
'platform:fleet:trip:dispatch-review',
'platform:fleet:trip:loading-review')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
USE baiyue_lastleg;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_pallet_spaces')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_pallet_spaces INT NULL COMMENT ''最大标准托位,未知时为空''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_load_kg')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_load_kg DECIMAL(12,3) NULL COMMENT ''最大载重,千克''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
SET @return_fleet_ddl = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_vehicle' AND column_name='max_volume_cbm')=0, 'ALTER TABLE ll_vehicle ADD COLUMN max_volume_cbm DECIMAL(12,3) NULL COMMENT ''可用容积,立方米''', 'SELECT 1');
PREPARE return_fleet_stmt FROM @return_fleet_ddl;
EXECUTE return_fleet_stmt;
DEALLOCATE PREPARE return_fleet_stmt;
CREATE TABLE IF NOT EXISTS ll_fleet_driver (
id BIGINT NOT NULL COMMENT '内部主键',
driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号',
warehouse_id BIGINT NULL COMMENT '所属公共仓库引用',
warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照',
warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照',
vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述',
languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域',
medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质',
training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质',
insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质',
name VARCHAR(128) NOT NULL COMMENT '姓名',
phone VARCHAR(64) NOT NULL COMMENT '联系电话',
ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派',
region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回',
license_number VARCHAR(128) NULL COMMENT '驾驶证编号',
license_type VARCHAR(64) NULL COMMENT '驾驶证类型',
license_issued_on DATE NULL COMMENT '驾驶证签发日期',
license_expires_on DATE NULL COMMENT '驾驶证有效截止日',
license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区',
license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_no (driver_no),
KEY idx_fleet_driver_page (active,review_status,updated_at,id),
KEY idx_fleet_driver_expiry (license_expires_on),
CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')),
CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')),
CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案';
CREATE TABLE IF NOT EXISTS ll_fleet_driver_history (
id BIGINT NOT NULL COMMENT '内部主键',
driver_id BIGINT NOT NULL COMMENT '司机内部引用',
record_version BIGINT NOT NULL COMMENT '操作后的档案版本',
action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作',
reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见',
license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用',
snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version),
KEY idx_fleet_driver_history_file (driver_id,license_file_id),
CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record (
id BIGINT NOT NULL COMMENT '记录内部主键',
record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号',
vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键',
vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照',
record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常',
occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)',
details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照',
files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人',
updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_no (record_no),
UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key),
KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id),
CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录';
CREATE TABLE IF NOT EXISTS ll_fleet_vehicle_record_history (
id BIGINT NOT NULL COMMENT '历史内部主键',
record_id BIGINT NOT NULL COMMENT '运维记录主键',
record_version BIGINT NOT NULL COMMENT '对应业务版本',
action VARCHAR(32) NOT NULL COMMENT '操作类型',
reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)',
snapshot_json JSON NOT NULL COMMENT '当时完整业务快照',
PRIMARY KEY (id),
UNIQUE KEY uk_vehicle_record_revision (record_id, record_version),
CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip (
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256',
status VARCHAR(16) NOT NULL COMMENT '车次状态',
warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用',
warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照',
warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照',
vehicle_id BIGINT NULL COMMENT '车辆档案引用',
vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照',
driver_id BIGINT NULL COMMENT '司机档案引用',
driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名',
driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话',
max_pallet_spaces INT NULL COMMENT '分配时托位容量快照',
max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克',
max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米',
planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC',
expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场',
departed_at DATETIME(3) NULL COMMENT '实际发车UTC',
completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC',
stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点',
planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量',
load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照',
loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核',
departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识',
departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要',
departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果',
completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照',
vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录',
driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录',
remark VARCHAR(1000) NULL COMMENT '备注',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '更新人',
updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_no (trip_no),
UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key),
KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id),
CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id),
CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id),
CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')),
CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer (
id BIGINT NOT NULL COMMENT '内部主键',
offer_no VARCHAR(64) NOT NULL COMMENT '拼车运力发布业务编号',
trip_id BIGINT NOT NULL COMMENT '唯一所属车次',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
status VARCHAR(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭',
cutoff_at DATETIME(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间',
contact_name VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系人',
contact_phone VARCHAR(128) NULL COMMENT '对内部销售公开的调度联系电话',
remark VARCHAR(1000) NULL COMMENT '公开承运说明',
published_fingerprint CHAR(64) NULL COMMENT '已核对路线车辆及容量计划事实摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(64) NOT NULL COMMENT '创建人',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_no(offer_no),
UNIQUE KEY uk_fleet_capacity_offer_trip(trip_id),
KEY idx_fleet_capacity_offer_status(status,cutoff_at,id),
CONSTRAINT fk_fleet_capacity_offer_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_capacity_offer_status CHECK(status IN ('DRAFT','OPEN','PAUSED','CLOSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内部拼车车次余位发布;容量实时计算不在此另存';
CREATE TABLE IF NOT EXISTS ll_fleet_capacity_offer_command (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次锁作用域',
operator_id VARCHAR(64) NOT NULL COMMENT '原请求操作者',
action VARCHAR(32) NOT NULL COMMENT '业务命令类型',
scope_no VARCHAR(64) NOT NULL COMMENT '发布或预订业务编号作用域',
idempotency_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '原请求业务内容摘要',
result_json JSON NOT NULL COMMENT '成功命令原始结果用于精确重放',
created_at DATETIME(3) NOT NULL COMMENT '命令完成UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_capacity_offer_command(trip_id,operator_id,action,scope_no,idempotency_key),
CONSTRAINT fk_fleet_capacity_command_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车发布及销售意向持久幂等结果';
CREATE TABLE IF NOT EXISTS ll_fleet_external_booking (
id BIGINT NOT NULL COMMENT '内部主键',
booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属',
customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照',
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键',
request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要',
confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键',
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
incoming_hash CHAR(64) NULL COMMENT '来货登记摘要',
receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键',
receipt_hash CHAR(64) NULL COMMENT '实收确认摘要',
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计实际到我仓UTC时间',
expected_pallet_count INT NULL COMMENT '预计实托数,与托位分开且未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_by VARCHAR(64) NOT NULL COMMENT '修改人',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
KEY idx_fleet_booking_offer(offer_id,status,id),
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_booking_no(booking_no),
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
KEY idx_fleet_booking_trip(trip_id,status,id),
UNIQUE KEY uk_fleet_booking_incoming(incoming_no),
KEY idx_fleet_booking_dispatch(dispatch_no),
CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实';
CREATE TABLE IF NOT EXISTS ll_fleet_pool_order_link (
id BIGINT NOT NULL COMMENT '内部主键',
booking_id BIGINT NOT NULL COMMENT '唯一外接预订',
booking_no VARCHAR(64) NOT NULL COMMENT '预订业务编号',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围',
business_type VARCHAR(32) NOT NULL COMMENT '本土散板或本土卡车商业类型',
business_order_id BIGINT NOT NULL COMMENT '对应商业订单内部关联',
business_order_no VARCHAR(64) NOT NULL COMMENT '本土商业订单业务编号',
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实我仓上车仓库',
destination_key VARCHAR(160) NOT NULL COMMENT '冻结卸货站匹配键',
delivery_mode VARCHAR(32) NOT NULL COMMENT '客户送仓或人工提货回仓',
delivery_instructions VARCHAR(1000) NULL COMMENT '实际到仓交货说明',
incoming_no VARCHAR(64) NULL COMMENT '真实来货业务编号,未登记为空',
lastleg_order_id BIGINT NULL COMMENT '唯一真实尾程来源,未发生为空',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车编号',
fulfillment_stage VARCHAR(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成',
stage_occurred_at DATETIME(3) NOT NULL COMMENT '实际阶段发生UTC时间',
actual_pallet_count INT NULL COMMENT '实际去重托盘数,未知为空',
actual_package_count INT NULL COMMENT '实际箱数,未知为空',
actual_pallet_spaces INT NULL COMMENT '实际核准占用托位,未知为空',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '桥记录版本',
creation_request_hash CHAR(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要',
created_by VARCHAR(64) NOT NULL COMMENT '确认承运操作者',
created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_pool_order_booking(booking_id),
UNIQUE KEY uk_fleet_pool_order_booking_no(booking_no),
UNIQUE KEY uk_fleet_pool_order_business(business_type,business_order_id),
UNIQUE KEY uk_fleet_pool_order_incoming(incoming_no),
UNIQUE KEY uk_fleet_pool_order_lastleg(lastleg_order_id),
KEY idx_fleet_pool_order_customer(customer_id,business_type,business_order_no),
KEY idx_fleet_pool_order_dispatch(customer_id,dispatch_no),
CONSTRAINT fk_fleet_pool_order_booking FOREIGN KEY(booking_id) REFERENCES ll_fleet_external_booking(id),
CONSTRAINT chk_fleet_pool_order_type CHECK(business_type IN ('LOCAL_LOOSEBOARD','US_TRUCK')),
CONSTRAINT chk_fleet_pool_order_actual CHECK((actual_pallet_count IS NULL OR actual_pallet_count >= 0) AND (actual_package_count IS NULL OR actual_package_count >= 0) AND (actual_pallet_spaces IS NULL OR actual_pallet_spaces >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费';
CREATE TABLE IF NOT EXISTS ll_fleet_resource_assignment (
resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER',
resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用',
trip_id BIGINT NOT NULL COMMENT '占用车次',
trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号',
PRIMARY KEY (resource_kind,resource_id),
KEY idx_fleet_resource_trip (trip_id),
CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_dispatch (
dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联',
dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号',
customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属',
trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用',
trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号',
package_count INT NOT NULL COMMENT '关联时待发运箱数快照',
bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人',
bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间',
review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核',
PRIMARY KEY (dispatch_id),
UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no),
KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id),
CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_history (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
record_version BIGINT NOT NULL COMMENT '操作后版本',
action VARCHAR(32) NOT NULL COMMENT '车次操作',
reason VARCHAR(1000) NULL COMMENT '操作原因',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version),
CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_operations_record (
id BIGINT NOT NULL COMMENT '出车实测历史主键',
trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号',
record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增',
details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照',
reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键',
request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_operations_version (trip_no,record_version),
UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_handover (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '所属车次',
trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号',
request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键',
request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要',
record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本',
handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间',
snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照',
vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果',
driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果',
created_by VARCHAR(64) NOT NULL COMMENT '记录操作人',
created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_handover_no (handover_no),
UNIQUE KEY uk_fleet_handover_request (trip_id, request_key),
UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version),
KEY idx_fleet_handover_page (trip_id, id),
CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接';
CREATE TABLE IF NOT EXISTS ll_fleet_finance_source (
id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键',
record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键',
source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增',
record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本',
operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识',
request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键',
request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀',
event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID',
payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC',
PRIMARY KEY (id),
UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version),
UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key),
UNIQUE KEY uk_fleet_finance_source_event (event_id),
CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0)
) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定';
CREATE TABLE IF NOT EXISTS ll_fleet_quote (
id BIGINT PRIMARY KEY COMMENT '报价记录主键',
quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号',
kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售',
party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识',
party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照',
origin_city VARCHAR(128) NOT NULL COMMENT '发货城市',
destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码',
valid_from DATE NOT NULL COMMENT '报价生效日期',
valid_to DATE NOT NULL COMMENT '报价失效日期含当天',
status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态',
natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要',
details_json JSON NOT NULL COMMENT '完整报价明细快照',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要',
created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识',
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC',
UNIQUE KEY uk_fleet_quote_no (quote_no),
UNIQUE KEY uk_fleet_quote_natural (natural_key),
UNIQUE KEY uk_fleet_quote_create (created_by,create_key),
KEY idx_fleet_quote_search (kind,status,valid_from,valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价';
CREATE TABLE IF NOT EXISTS ll_fleet_quote_history (
id BIGINT PRIMARY KEY COMMENT '报价历史主键',
quote_id BIGINT NOT NULL COMMENT '报价记录标识',
record_version BIGINT NOT NULL COMMENT '报价记录版本',
action VARCHAR(24) NOT NULL COMMENT '报价操作类型',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识',
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
snapshot_json JSON NOT NULL COMMENT '报价完整版本快照',
UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost (
id BIGINT NOT NULL COMMENT '内部主键',
trip_id BIGINT NOT NULL COMMENT '车次内部引用',
cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号',
category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER',
currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总',
amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款',
incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间',
remark VARCHAR(1000) NULL COMMENT '费用备注',
record_version BIGINT NOT NULL COMMENT '成本记录版本',
request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要',
created_by VARCHAR(64) NOT NULL COMMENT '登记人',
updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人',
created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间',
updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_no(cost_no),
UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key),
KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id),
CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id),
CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0),
CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')),
CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款';
CREATE TABLE IF NOT EXISTS ll_fleet_trip_cost_history (
id BIGINT NOT NULL COMMENT '内部主键',
cost_id BIGINT NOT NULL COMMENT '成本记录内部引用',
record_version BIGINT NOT NULL COMMENT '成本更正后版本',
action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE',
reason VARCHAR(1000) NULL COMMENT '登记或更正说明',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照',
PRIMARY KEY(id),
UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version),
CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史';
-42
View File
@@ -1,42 +0,0 @@
# Event Bridge Service 模块指引
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。
## 职责边界
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引
| 功能 | 入口 |
|---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg |
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。
- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。
## 修改与验证
- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
@@ -1,67 +0,0 @@
# Event Bridge 开发阶段可观测性与 SLO 合同
## 状态与边界
本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了
Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。
指标标签只允许 `target_region`、`event_type`、`result`:
- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。
- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。
- `result` 仅为代码中冻结的接收或传输结果枚举。
- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。
## 指标合同
| Prometheus 指标 | 含义 |
|---|---|
| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` |
| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` |
| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 |
| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 |
| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 |
| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 |
`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数,
不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。
Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge,
不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有
`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。
## 开发阶段 SLI 与 SLO
SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT`
不重复进入分母。滚动 30 天目标:
- 99% 的新接收跨区事件在 60 秒内得到下游确认。
- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。
代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组:
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99
```
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999
```
## 告警合同
规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同:
- 新增 DEAD:下一次评估立即告警,不设置持续等待。
`sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0`
- 最老待处理年龄预警:任一有限标签序列超过 120 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120`
- 最老待处理年龄严重:任一有限标签序列超过 900 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900`
- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。
告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。
@@ -1,16 +0,0 @@
# Event Bridge Trace 传播基线
- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。
- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。
- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。
- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。
- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。
- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id`
关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。
## 共享契约缺口
当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能
无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由
`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享
Payload、信封字段、哈希规则或可靠性状态机。
-23
View File
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
</parent>
<artifactId>event-bridge-service</artifactId>
<name>event-bridge-service</name>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -1,20 +0,0 @@
package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
@SpringBootApplication
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication {
public static void main(String[] args) {
SpringApplication.run(EventBridgeApplication.class, args);
}
}
@@ -1,34 +0,0 @@
package com.baiyue.eventbridge.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
public record EventDispatcherProperties(
URI consumerUrl,
int batchSize,
int maxAttempts,
Duration leaseDuration,
Duration initialBackoff,
Duration maxBackoff,
Duration connectTimeout,
Duration requestTimeout) {
public EventDispatcherProperties {
consumerUrl = consumerUrl == null
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
batchSize = batchSize <= 0 ? 50 : batchSize;
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
}
private static Duration positiveOrDefault(Duration value, Duration fallback) {
return value == null || value.isNegative() || value.isZero() ? fallback : value;
}
}
@@ -1,73 +0,0 @@
package com.baiyue.eventbridge.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.eventbridge.dto.BridgeStatus;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.dto.EventReplayRequest;
import com.baiyue.eventbridge.service.EventInboxService;
import com.baiyue.eventbridge.service.EventReplayService;
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
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 org.springframework.web.bind.annotation.PathVariable;
/**
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
*/
@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
@RestController
@RequestMapping("/internal/event-bridge")
public class EventBridgeController {
@Resource private EventInboxService eventInboxService;
@Resource private EventReplayService eventReplayService;
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:OVERSEAS}")
private String region;
@Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "内部服务令牌未配置,接收接口关闭")
})
@PostMapping("/events")
public ApiResponse<EventReceiveResult> receive(
@Parameter(description = "内部服务共享令牌")
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope envelope) {
internalServiceTokenGuard.verify(token);
return ApiResponse.success(eventInboxService.receive(envelope));
}
@Operation(summary = "人工补投死信事件",
description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因")
@PostMapping("/events/{eventId}/replay")
public ApiResponse<Void> replay(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") String operatorId,
@PathVariable String eventId,
@RequestBody EventReplayRequest request) {
internalServiceTokenGuard.verify(token);
eventReplayService.replayDead(eventId, operatorId, request.reason());
return ApiResponse.success(null);
}
@Operation(summary = "查询事件桥能力状态")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
@GetMapping("/status")
public ApiResponse<BridgeStatus> status() {
return ApiResponse.success(new BridgeStatus("运行中", region, true, false));
}
}
@@ -1,24 +0,0 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("bridge_delivery_attempt")
public class BridgeDeliveryAttempt {
@TableId(type = IdType.AUTO)
private Long id;
private String eventId;
private String sourceRegion;
private String targetRegion;
private Integer attemptNo;
private String status;
private String responseCode;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
}
@@ -1,42 +0,0 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
import java.time.Instant;
/**
* 事件桥入站幂等记录。
*/
@Data
@TableName("bridge_inbox")
public class BridgeInbox {
@TableId
private String eventId;
private String idempotencyKey;
private String eventType;
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
private Long handoverVersion;
private String traceId;
private String correlationId;
private String causationId;
private Instant occurredAt;
private String payloadHash;
private String payloadJson;
private String status;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private String lastError;
private LocalDateTime receivedAt;
private LocalDateTime processedAt;
}
@@ -1,14 +0,0 @@
package com.baiyue.eventbridge.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 事件桥能力状态,不代表生产网络链路已经开通。
*/
@Schema(description = "事件桥能力状态")
public record BridgeStatus(
@Schema(description = "服务状态") String status,
@Schema(description = "当前区域") String region,
@Schema(description = "数据库幂等接收是否启用") boolean inboxEnabled,
@Schema(description = "生产级跨区传输是否启用") boolean productionTransportEnabled) {
}
@@ -1,15 +0,0 @@
package com.baiyue.eventbridge.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 事件接收结果。
*/
@Schema(description = "事件接收结果")
public record EventReceiveResult(
@Schema(description = "事件标识") String eventId,
@Schema(description = "接收结果:已接收或重复") String result,
@Schema(description = "接收时间") LocalDateTime receivedAt) {
}
@@ -1,4 +0,0 @@
package com.baiyue.eventbridge.dto;
public record EventReplayRequest(String reason) {
}
@@ -1,9 +0,0 @@
package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
}
@@ -1,81 +0,0 @@
package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 入站事件持久化映射。
*/
@Mapper
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select("""
SELECT event_id FROM bridge_inbox
WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit}
""")
List<String> dispatchCandidates(@Param("limit") int limit);
@Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
WHERE event_id=#{eventId}
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""")
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("leaseSeconds") long leaseSeconds);
@Update("""
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
@Update("""
UPDATE bridge_inbox SET retry_count=retry_count+1,
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
lease_owner=NULL, lease_until=NULL, last_error=#{error}
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("maxAttempts") int maxAttempts,
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
int nextAttemptNo(@Param("eventId") String eventId);
@Update("""
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
WHERE event_id=#{eventId} AND status='DEAD'
""")
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
/**
* Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume;
* Prometheus collection never invokes this query directly.
*/
@Select("""
SELECT target_region AS targetRegion, event_type AS eventType,
SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount,
COALESCE(TIMESTAMPDIFF(MICROSECOND,
MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END),
CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds,
SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount
FROM bridge_inbox
WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')
GROUP BY target_region, event_type
""")
List<InboxMetricsSnapshot> observabilitySnapshots();
}
@@ -1,103 +0,0 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.MultiGauge;
import io.micrometer.core.instrument.Tags;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */
@Component
@Slf4j
public class EventBridgeInboxMetricsSampler {
public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog";
public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds";
public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead";
private final BridgeInboxMapper inboxMapper;
private final MultiGauge backlog;
private final MultiGauge oldestAge;
private final MultiGauge dead;
public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) {
this.inboxMapper = inboxMapper;
this.backlog = MultiGauge.builder(BACKLOG_METRIC)
.description("Events waiting for downstream acknowledgement").register(registry);
this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC)
.description("Age in seconds of the oldest pending Inbox event").register(registry);
this.dead = MultiGauge.builder(DEAD_METRIC)
.description("Current number of DEAD Inbox events").register(registry);
}
@Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}",
fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}")
public void sample() {
final List<InboxMetricsSnapshot> snapshots;
try {
snapshots = inboxMapper.observabilitySnapshots();
} catch (RuntimeException exception) {
log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception);
return;
}
Map<MetricKey, Aggregate> aggregates = aggregate(snapshots);
backlog.register(rows(aggregates, MetricValue.BACKLOG), true);
oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true);
dead.register(rows(aggregates, MetricValue.DEAD), true);
}
private Map<MetricKey, Aggregate> aggregate(List<InboxMetricsSnapshot> snapshots) {
Map<MetricKey, Aggregate> result = new LinkedHashMap<>();
if (snapshots == null) {
return result;
}
for (InboxMetricsSnapshot snapshot : snapshots) {
MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()),
EventBridgeMetrics.eventType(snapshot.getEventType()));
result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot);
}
return result;
}
private List<MultiGauge.Row<?>> rows(Map<MetricKey, Aggregate> aggregates, MetricValue value) {
List<MultiGauge.Row<?>> rows = new ArrayList<>();
aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of(
Tags.of("target_region", key.targetRegion, "event_type", key.eventType),
value.value(aggregate))));
return rows;
}
private record MetricKey(String targetRegion, String eventType) { }
private static final class Aggregate {
private long backlog;
private double oldestAge;
private long dead;
void add(InboxMetricsSnapshot snapshot) {
backlog += snapshot.getBacklogCount();
oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds());
dead += snapshot.getDeadCount();
}
}
private enum MetricValue {
BACKLOG {
@Override Number value(Aggregate aggregate) { return aggregate.backlog; }
},
OLDEST_AGE {
@Override Number value(Aggregate aggregate) { return aggregate.oldestAge; }
},
DEAD {
@Override Number value(Aggregate aggregate) { return aggregate.dead; }
};
abstract Number value(Aggregate aggregate);
}
}
@@ -1,128 +0,0 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.domain.BridgeInbox;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Set;
/** Low-cardinality transport metrics for the cross-region event bridge. */
@Component
@Slf4j
public class EventBridgeMetrics {
public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive";
public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport";
public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency";
private static final Set<String> KNOWN_EVENT_TYPES = Set.of(
EventTypes.ORDER_APPROVED,
EventTypes.ORDER_CANCELLED,
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.OVERSEAS_DELIVERY_COMPLETED,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
EventTypes.WALLET_COMMAND_REQUESTED,
EventTypes.WALLET_COMMAND_COMPLETED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
EventTypes.LAST_MILE_HANDOFF_ACCEPTED,
EventTypes.LAST_MILE_HANDOFF_REJECTED,
EventTypes.LAST_MILE_MILESTONE_CHANGED,
EventTypes.LAST_MILE_COMPLETED,
EventTypes.TRACKING_MILESTONE_PROJECTED);
private final MeterRegistry registry;
private final Clock clock;
@Autowired
public EventBridgeMetrics(MeterRegistry registry) {
this(registry, Clock.systemDefaultZone());
}
EventBridgeMetrics(MeterRegistry registry, Clock clock) {
this.registry = registry;
this.clock = clock;
}
public void recordReceive(EventEnvelope envelope, ReceiveResult result) {
try {
registry.counter(RECEIVE_METRIC,
"target_region", targetRegion(envelope.targetRegion().name()),
"event_type", eventType(envelope.eventType()),
"result", result.name()).increment();
} catch (RuntimeException exception) {
log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception);
}
}
public void recordTransport(BridgeInbox event, TransportResult result) {
try {
String targetRegion = targetRegion(event.getTargetRegion());
String eventType = eventType(event.getEventType());
registry.counter(TRANSPORT_METRIC,
"target_region", targetRegion,
"event_type", eventType,
"result", result.name()).increment();
if (result.acknowledged() && event.getReceivedAt() != null) {
Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock));
if (latency.isNegative()) {
latency = Duration.ZERO;
}
Timer.builder(DELIVERY_LATENCY_METRIC)
.description("Time from Event Bridge receipt to downstream acknowledgement")
.serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15))
.tags("target_region", targetRegion, "event_type", eventType)
.register(registry).record(latency);
}
} catch (RuntimeException exception) {
log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception);
}
}
public static String targetRegion(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER";
}
public static String eventType(String value) {
return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER";
}
public enum ReceiveResult {
RECEIVED,
DUPLICATE,
CONFLICT
}
public enum TransportResult {
DELIVERED(true),
RETRY(false),
DEAD(false),
ACKNOWLEDGED_LEASE_LOST(true),
FAILED_LEASE_LOST(false),
MANUAL_REPLAY(false);
private final boolean acknowledged;
TransportResult(boolean acknowledged) {
this.acknowledged = acknowledged;
}
boolean acknowledged() {
return acknowledged;
}
}
}
@@ -1,50 +0,0 @@
package com.baiyue.eventbridge.metrics;
/** Result row of the bounded, scheduled Inbox observability aggregation. */
public class InboxMetricsSnapshot {
private String targetRegion;
private String eventType;
private long backlogCount;
private double oldestPendingAgeSeconds;
private long deadCount;
public String getTargetRegion() {
return targetRegion;
}
public void setTargetRegion(String targetRegion) {
this.targetRegion = targetRegion;
}
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public long getBacklogCount() {
return backlogCount;
}
public void setBacklogCount(long backlogCount) {
this.backlogCount = backlogCount;
}
public double getOldestPendingAgeSeconds() {
return oldestPendingAgeSeconds;
}
public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) {
this.oldestPendingAgeSeconds = oldestPendingAgeSeconds;
}
public long getDeadCount() {
return deadCount;
}
public void setDeadCount(long deadCount) {
this.deadCount = deadCount;
}
}
@@ -1,32 +0,0 @@
package com.baiyue.eventbridge.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* 内部事件接收接口的共享令牌校验器;未配置令牌时默认关闭接收。
*/
@Component
public class InternalServiceTokenGuard {
private final String expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
this.expectedToken = expectedToken;
}
public void verify(String actualToken) {
if (expectedToken == null || expectedToken.isBlank()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "内部事件接收令牌未配置,接口已关闭");
}
byte[] expected = expectedToken.getBytes(StandardCharsets.UTF_8);
byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expected, actual)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效");
}
}
}
@@ -1,149 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
/**
* 入站事件校验与幂等落库服务。
*/
@Service
public class EventInboxService {
@Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper;
@Resource private EventBridgeMetrics metrics;
@Value("${baiyue.region:CN}")
private String localRegion;
/** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */
@Value("${baiyue.event-bridge.accept-any-region:false}")
private boolean acceptAnyRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
envelope.requireValid();
if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(
EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
if (existing != null) {
return duplicateOrConflict(existing, envelope);
}
String idempotencyKey = idempotencyKey(envelope);
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (sameBusinessEvent != null) {
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
}
BridgeInbox inbox = toInbox(envelope);
try {
inboxStore.insert(inbox);
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED);
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (concurrent == null) {
throw exception;
}
return Objects.equals(concurrent.getEventId(), envelope.eventId())
? duplicateOrConflict(concurrent, envelope)
: duplicateBusinessEventOrConflict(concurrent, envelope);
}
}
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameBusinessEvent(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
}
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
}
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameEnvelope(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
}
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
}
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
return sameBusinessEvent(existing, envelope)
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
}
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
return existing.getPayloadHash() != null
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
&& Objects.equals(existing.getEventType(), envelope.eventType())
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
&& Objects.equals(existing.getCustomerId(), envelope.tenantId())
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId());
}
private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setIdempotencyKey(idempotencyKey(envelope));
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
inbox.setHandoverVersion(envelope.handoverVersion());
inbox.setTraceId(envelope.traceId());
inbox.setCorrelationId(envelope.correlationId());
inbox.setCausationId(envelope.causationId());
inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt()));
inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT));
try {
inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload()));
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("事件负载无法序列化", exception);
}
inbox.setStatus("RECEIVED");
inbox.setReceivedAt(LocalDateTime.now());
return inbox;
}
private static Instant normalizeOccurredAt(Instant value) {
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
}
private static String idempotencyKey(EventEnvelope envelope) {
return envelope.sourceRegion() + ":" + envelope.correlationId();
}
}
@@ -1,53 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class EventReplayService {
@Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private EventBridgeMetrics metrics;
@Transactional
public void replayDead(String eventId, String operatorId, String reason) {
if (operatorId == null || operatorId.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供操作人");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供原因");
}
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
}
BridgeInbox event = inboxMapper.selectById(eventId);
if (event == null) {
throw new IllegalStateException("事件补投状态已更新但事件记录不存在");
}
LocalDateTime now = LocalDateTime.now();
BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt();
audit.setEventId(eventId);
audit.setSourceRegion(event.getSourceRegion());
audit.setTargetRegion(event.getTargetRegion());
audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId));
audit.setStatus("MANUAL_REPLAY");
audit.setErrorMessage(auditReason);
audit.setStartedAt(now);
audit.setFinishedAt(now);
attemptMapper.insert(audit);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY);
}
private static String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}
@@ -1,7 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FinanceEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -1,7 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FirstlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -1,61 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFinanceEventClient implements FinanceEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}")
private URI consumerUrl;
@Value("${baiyue.event-dispatcher.finance-wallet-consumer-url:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}")
private URI walletConsumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
URI target = EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType())
? walletConsumerUrl : consumerUrl;
HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(target)
.timeout(properties.requestTimeout()).header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
try { body = objectMapper.readTree(response.body()); }
catch (Exception ignored) { body = null; }
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode()
+ ", code=" + (body == null ? "missing" : body.path("code").asText("missing")));
}
return String.valueOf(response.statusCode());
}
}
@@ -1,75 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFirstlegEventClient implements FirstlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}")
private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken);
HttpRequest request = tracePropagation.apply(requestBuilder)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody;
try {
responseBody = objectMapper.readTree(response.body());
} catch (Exception ignored) {
responseBody = null;
}
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
}
return String.valueOf(response.statusCode());
}
static String failureMessage(int statusCode, JsonNode body) {
String code = body == null ? "missing" : body.path("code").asText("missing");
String message = body == null ? "non-json response" : body.path("message").asText("missing");
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
+ ", traceId=" + bounded(traceId);
}
private static String bounded(String value) {
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
return safe.length() <= 240 ? safe : safe.substring(0, 240);
}
}
@@ -1,78 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpLastlegEventClient implements LastlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.security.internal-service-token:}")
private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
String body = objectMapper.writeValueAsString(envelope);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl())
.timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken);
HttpRequest request = tracePropagation.apply(requestBuilder)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody;
try {
responseBody = objectMapper.readTree(response.body());
} catch (Exception ignored) {
responseBody = null;
}
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException(failureMessage(response.statusCode(), responseBody));
}
return String.valueOf(response.statusCode());
}
static String failureMessage(int statusCode, JsonNode body) {
String code = body == null ? "missing" : body.path("code").asText("missing");
String message = body == null ? "non-json response" : body.path("message").asText("missing");
String requestId = body == null ? "missing" : body.path("requestId").asText("missing");
String traceId = body == null ? "missing" : body.path("traceId").asText("missing");
return "尾程消费失败: HTTP " + statusCode + ", code=" + bounded(code)
+ ", message=" + bounded(message) + ", requestId=" + bounded(requestId)
+ ", traceId=" + bounded(traceId);
}
private static String bounded(String value) {
String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " ");
return safe.length() <= 240 ? safe : safe.substring(0, 240);
}
}
@@ -1,64 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient {
private final ObjectMapper objectMapper;
private final EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) {
this.objectMapper = objectMapper;
this.properties = properties;
}
@PostConstruct void initialize() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken);
HttpRequest request = tracePropagation.apply(requestBuilder)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
try { body = objectMapper.readTree(response.body()); }
catch (Exception exception) { body = null; }
requireAcknowledgement(response.statusCode(), body, row.getEventId());
return String.valueOf(response.statusCode());
}
static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) {
if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText())
|| !eventId.equals(body.path("data").path("eventId").asText())) {
throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode);
}
}
}
@@ -1,12 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
/**
* 入站幂等存储边界,便于隔离数据库实现并进行单元测试。
*/
public interface InboxStore {
BridgeInbox findByEventId(String eventId);
BridgeInbox findByIdempotencyKey(String idempotencyKey);
void insert(BridgeInbox inbox);
}
@@ -1,7 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface LastlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -1,31 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
*/
@Repository
public class MybatisInboxStore implements InboxStore {
@Resource private BridgeInboxMapper mapper;
@Override
public BridgeInbox findByEventId(String eventId) {
return mapper.selectById(eventId);
}
@Override
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
}
@Override
public void insert(BridgeInbox inbox) {
mapper.insert(inbox);
}
}
@@ -1,141 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.baiyue.eventbridge.trace.EventBridgeTraceScope;
import com.baiyue.common.web.CorrelationContext;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.slf4j.MDC;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
@Component
@Slf4j
public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient;
@Resource private FinanceEventClient financeEventClient;
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeMetrics metrics;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}")
public void dispatch() {
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
dispatchOne(eventId);
}
}
void dispatchOne(String eventId) {
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
return;
}
BridgeInbox event = inboxMapper.selectById(eventId);
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
dispatchClaimed(eventId, event);
}
}
private void dispatchClaimed(String eventId, BridgeInbox event) {
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
String responseCode;
try {
responseCode = deliver(event);
} catch (Exception exception) {
String error = abbreviate(exception.getMessage(), 1000);
int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(),
backoffSeconds(retryAttempt), error);
String attemptStatus = marked == 1
? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY")
: "FAILED_LEASE_LOST";
finishAttempt(attempt, attemptStatus, null, error);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus));
log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error);
return;
}
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投";
finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST);
log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}",
eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID));
return;
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED);
}
private String deliver(BridgeInbox event) throws Exception {
String eventType = event.getEventType();
if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
}
return switch (event.getTargetRegion()) {
case "CN" -> FINANCE_EVENT_TYPES.contains(eventType)
? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
};
}
private static final Set<String> SECURITY_EVENT_TYPES = Set.of(
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED);
private static final Set<String> FINANCE_EVENT_TYPES = Set.of(
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
EventTypes.WALLET_COMMAND_REQUESTED);
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId());
attempt.setSourceRegion(event.getSourceRegion());
attempt.setTargetRegion(event.getTargetRegion());
attempt.setAttemptNo(attemptNo);
attempt.setStatus("PROCESSING");
attempt.setStartedAt(LocalDateTime.now());
attemptMapper.insert(attempt);
return attempt;
}
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
attempt.setStatus(status);
attempt.setResponseCode(responseCode);
attempt.setErrorMessage(error);
attempt.setFinishedAt(LocalDateTime.now());
attemptMapper.updateById(attempt);
}
private long backoffSeconds(int attemptNo) {
long initial = properties.initialBackoff().toSeconds();
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
return Math.min(delay, properties.maxBackoff().toSeconds());
}
private static String abbreviate(String value, int maxLength) {
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
}
}
@@ -1,7 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface SecurityProjectionEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -1,19 +0,0 @@
package com.baiyue.eventbridge.trace;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.common.web.OutboundTraceHeaders;
import org.springframework.stereotype.Component;
import java.net.http.HttpRequest;
/** Applies a fresh child span to one raw JDK HttpClient request. */
@Component
public class EventBridgeHttpTracePropagation {
public HttpRequest.Builder apply(HttpRequest.Builder builder) {
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
return builder
.setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId())
.setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId())
.setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
}
}
@@ -1,35 +0,0 @@
package com.baiyue.eventbridge.trace;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.common.web.OutboundTraceHeaders;
import com.baiyue.eventbridge.domain.BridgeInbox;
import org.slf4j.MDC;
import java.util.Map;
/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */
public final class EventBridgeTraceScope implements AutoCloseable {
private final Map<String, String> previous;
private EventBridgeTraceScope(Map<String, String> previous) {
this.previous = previous;
}
public static EventBridgeTraceScope open(BridgeInbox event) {
Map<String, String> previous = MDC.getCopyOfContextMap();
OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId());
MDC.put(CorrelationContext.REQUEST_ID, local.requestId());
MDC.put(CorrelationContext.TRACE_ID, local.traceId());
MDC.put(CorrelationContext.SPAN_ID, local.spanId());
return new EventBridgeTraceScope(previous);
}
@Override
public void close() {
if (previous == null) {
MDC.clear();
} else {
MDC.setContextMap(previous);
}
}
}
@@ -1,60 +0,0 @@
server:
port: ${EVENT_BRIDGE_PORT:9312}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: event-bridge-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:}
config:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:CN}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}
finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
management:
endpoints:
web:
exposure:
include: health,info,prometheus
springdoc:
api-docs:
path: /v3/api-docs
@@ -1,80 +0,0 @@
CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_event_bridge;
CREATE TABLE IF NOT EXISTS bridge_outbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
handover_version BIGINT NULL COMMENT '跨区交接版本',
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
payload_json JSON NOT NULL COMMENT '事件负载',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
lease_owner VARCHAR(128) NULL COMMENT '租约持有者',
lease_until DATETIME(3) NULL COMMENT '租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最后错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
delivered_at DATETIME(3) NULL COMMENT '投递成功时间',
PRIMARY KEY (event_id),
UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key),
KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='跨区域事件待投递箱';
CREATE TABLE IF NOT EXISTS bridge_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
handover_version BIGINT NULL COMMENT '跨区交接版本',
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
payload_json JSON NOT NULL COMMENT '事件负载',
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态',
lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者',
lease_until DATETIME(3) NULL COMMENT '消费租约截止时间',
retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次消费时间',
last_error VARCHAR(1000) NULL COMMENT '最后错误',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
processed_at DATETIME(3) NULL COMMENT '消费完成时间',
PRIMARY KEY (event_id),
UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key),
KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='跨区域事件已接收箱';
CREATE TABLE IF NOT EXISTS bridge_delivery_attempt (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
attempt_no INT NOT NULL COMMENT '投递序号',
status VARCHAR(32) NOT NULL COMMENT '投递结果',
response_code VARCHAR(64) NULL COMMENT '对端响应码',
error_message VARCHAR(1000) NULL COMMENT '错误信息',
started_at DATETIME(3) NOT NULL COMMENT '开始时间',
finished_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_bridge_attempt (event_id, attempt_no),
KEY idx_bridge_attempt_event (event_id, started_at)
) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录';
@@ -1,54 +0,0 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
class EventBridgeInboxMetricsSamplerTest {
@Test
void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() {
BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
7, 121.5, 2);
InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1);
when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown));
EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry);
sampler.sample();
verify(mapper).observabilitySnapshots();
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC,
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0);
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC,
"OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5);
assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC,
"OTHER", "OTHER")).isEqualTo(1.0);
verifyNoMoreInteractions(mapper);
}
private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog,
double oldestAge, long dead) {
InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot();
snapshot.setTargetRegion(region);
snapshot.setEventType(eventType);
snapshot.setBacklogCount(backlog);
snapshot.setOldestPendingAgeSeconds(oldestAge);
snapshot.setDeadCount(dead);
return snapshot;
}
private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) {
return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value();
}
}
@@ -1,78 +0,0 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.domain.BridgeInbox;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import io.micrometer.core.instrument.MeterRegistry;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
class EventBridgeMetricsTest {
@Test
void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception {
assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class)
.isAnnotationPresent(Autowired.class)).isTrue();
}
@Test
void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new);
context.register(EventBridgeMetrics.class);
context.refresh();
assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull();
}
}
@Test
void collapsesUnboundedRegionAndEventTypeLabelsToOther() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
BridgeInbox event = new BridgeInbox();
event.setTargetRegion("customer-controlled-region");
event.setEventType("dynamic-event-123456");
metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY);
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY")
.counter().count()).isEqualTo(1.0);
assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags())
.noneMatch(tag -> tag.getValue().contains("dynamic-event")));
assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER");
}
@Test
void everyFrozenEventTypeHasABoundedTagValue() {
assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields())
.filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class)
.map(field -> {
try {
return (String) field.get(null);
} catch (IllegalAccessException exception) {
throw new AssertionError(exception);
}
})).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value));
}
@Test
void metricsFailureDoesNotChangeTransportControlFlow() {
EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class));
BridgeInbox event = new BridgeInbox();
event.setTargetRegion("CN");
event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED);
assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY))
.doesNotThrowAnyException();
}
}
@@ -1,41 +0,0 @@
package com.baiyue.eventbridge.metrics;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeObservabilityContractTest {
@Test
void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception {
Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots")
.getAnnotation(Select.class);
String query = String.join(" ", annotation.value()).replaceAll("\\s+", " ");
String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql"));
assertThat(query)
.contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')")
.contains("GROUP BY target_region, event_type")
.doesNotContain("event_id AS", "customer_id AS", "last_error AS");
assertThat(schema).contains(
"KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)");
}
@Test
void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception {
String contract = Files.readString(Path.of("docs/development-observability-slo.md"));
assertThat(contract)
.contains("99% 的新接收跨区事件在 60 秒内")
.contains("99.9% 的新接收跨区事件在 15 分钟内")
.contains("result=\"DEAD\"", "> 120", "> 900")
.contains("不表示任何环境已经部署")
.contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)")
.doesNotContain("customer_id\"", "event_id\"");
}
}
@@ -1,31 +0,0 @@
package com.baiyue.eventbridge.security;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
@Test
void 正确令牌允许访问() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
}
@Test
void 未配置令牌时默认关闭接收接口() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertThatThrownBy(() -> guard.verify("anything"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("接口已关闭");
}
@Test
void 错误令牌被拒绝() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("令牌无效");
}
}
@@ -1,39 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeReliabilitySqlContractTest {
@Test
void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception {
String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class);
String claim = sql(BridgeInboxMapper.class.getMethod(
"claim", String.class, String.class, long.class), Update.class);
String failed = sql(BridgeInboxMapper.class.getMethod(
"markFailed", String.class, String.class, int.class, long.class, String.class), Update.class);
String processed = sql(BridgeInboxMapper.class.getMethod(
"markProcessed", String.class, String.class), Update.class);
assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)",
"status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at");
assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD",
"status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)");
assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'",
"next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}");
assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL",
"status='PROCESSING'", "lease_owner=#{owner}");
}
private <A extends java.lang.annotation.Annotation> String sql(Method method, Class<A> type) {
A annotation = method.getAnnotation(type);
String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value();
return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " ");
}
}
@@ -1,218 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class EventInboxServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
private final EventInboxService service = service("OVERSEAS");
@Test
void 首次事件落库并返回已接收() {
EventEnvelope envelope = envelope("sha256:placeholder");
envelope = withHash(envelope);
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class));
assertThat(receiveCount("RECEIVED")).isEqualTo(1.0);
}
@Test
void 拒绝旧数字规范化摘要() {
EventEnvelope source = envelope("sha256:placeholder");
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
}
@Test
void 相同事件和摘要重复接收不再次写入() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
assertThat(service.receive(envelope).result()).isEqualTo("重复");
assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0);
}
@Test
void 数据库毫秒精度截断后相同事件仍判定为重复() {
EventEnvelope source = envelope("sha256:placeholder");
EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(),
source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(),
source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(),
source.payload(), source.payloadHash());
EventEnvelope envelope = withHash(precise);
BridgeInbox existing = existing(envelope, envelope.payloadHash());
existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z"));
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void 相同事件但摘要不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("拒绝覆盖");
assertThat(receiveCount("CONFLICT")).isEqualTo(1.0);
}
@Test
void 相同事件和摘要但聚合标识不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(),
envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(),
envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash()));
assertThatThrownBy(() -> service.receive(conflicting))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("事件信封");
}
@Test
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(null);
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
assertThat(service.receive(replay).result()).isEqualTo("重复");
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
driftPayload.put("containerNo", "DIFFERENT");
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
null, driftPayload, driftHash);
assertThatThrownBy(() -> service.receive(drift))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test
void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash()));
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void 目标区域与当前部署区域不一致时拒绝接收() {
EventInboxService cnService = service("CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThatThrownBy(() -> cnService.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("目标区域");
}
@Test
void 本地单实例模式允许接收不同目标区域事件() {
EventInboxService localService = service("CN", true);
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThat(localService.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class));
}
private EventEnvelope envelope(String hash) {
return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1,
Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS,
"20001", "ORDER", "90001", 1, null, "trace-1", "flow-1", null,
objectMapper.createObjectNode().put("orderId", "90001"), hash);
}
private EventInboxService service(String region) {
return service(region, false);
}
private EventInboxService service(String region, boolean acceptAnyRegion) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
ReflectionTestUtils.setField(service, "localRegion", region);
ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion);
return service;
}
private double receiveCount(String result) {
return registry.get(EventBridgeMetrics.RECEIVE_METRIC)
.tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result)
.counter().count();
}
private EventEnvelope withHash(EventEnvelope source) {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), hash);
}
private BridgeInbox existing(EventEnvelope envelope, String hash) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
inbox.setHandoverVersion(envelope.handoverVersion());
inbox.setOccurredAt(envelope.occurredAt());
inbox.setTraceId(envelope.traceId());
inbox.setCorrelationId(envelope.correlationId());
inbox.setCausationId(envelope.causationId());
inbox.setPayloadHash(hash);
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
return inbox;
}
}
@@ -1,71 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.apache.ibatis.annotations.Update;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class EventReplayServiceTest {
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry);
private final EventReplayService service = service();
@Test
void requeuesDeadEventWithAuditReason() {
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
BridgeInbox event = new BridgeInbox();
event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS");
event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
when(mapper.selectById("event-1")).thenReturn(event);
when(mapper.nextAttemptNo("event-1")).thenReturn(4);
service.replayDead("event-1", "operator-7", "incident-42 approved");
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).insert(captor.capture());
assertThat(captor.getValue().getAttemptNo()).isEqualTo(4);
assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY");
assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42");
assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", "OVERSEAS", "event_type", event.getEventType(),
"result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0);
}
@Test
void rejectsEventThatIsNotDead() {
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
.isInstanceOf(IllegalStateException.class);
}
@Test
void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception {
String sql = String.join(" ", BridgeInboxMapper.class
.getMethod("replayDead", String.class, String.class)
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'")
.doesNotContain("payload_json=", "payload_hash=");
}
private EventReplayService service() {
EventReplayService service = new EventReplayService();
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
return service;
}
}
@@ -1,19 +0,0 @@
package com.baiyue.eventbridge.service;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class HttpFinanceEventClientTest {
@Test
void walletConsumerUrlCanBeConfiguredForContainerDeployment() throws Exception {
String application = Files.readString(Path.of("src/main/resources/application.yml"));
assertThat(application).contains(
"finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:"
+ "http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}");
}
}
@@ -1,26 +0,0 @@
package com.baiyue.eventbridge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HttpFirstlegEventClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception {
var body = objectMapper.readTree("""
{"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"}
""");
assertThat(HttpFirstlegEventClient.failureMessage(200, body))
.isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1");
}
@Test
void nonJsonFailureIsStillDiagnosableWithoutRawBody() {
assertThat(HttpFirstlegEventClient.failureMessage(503, null))
.isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing");
}
}
@@ -1,26 +0,0 @@
package com.baiyue.eventbridge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HttpLastlegEventClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void failureIncludesBusinessCodeMessageAndTraceEvidence() throws Exception {
var body = objectMapper.readTree("""
{"code":"A0400","message":"入仓计划数据无效","requestId":"request-1","traceId":"trace-1"}
""");
assertThat(HttpLastlegEventClient.failureMessage(200, body))
.isEqualTo("尾程消费失败: HTTP 200, code=A0400, message=入仓计划数据无效, requestId=request-1, traceId=trace-1");
}
@Test
void nonJsonFailureIsStillDiagnosableWithoutLeakingRawBody() {
assertThat(HttpLastlegEventClient.failureMessage(502, null))
.isEqualTo("尾程消费失败: HTTP 502, code=missing, message=non-json response, requestId=missing, traceId=missing");
}
}
@@ -1,44 +0,0 @@
package com.baiyue.eventbridge.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class HttpSecurityProjectionEventClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception {
var acknowledged = objectMapper.readTree("""
{"code":"200","data":{"eventId":"event-1","result":"APPLIED"}}
""");
var businessFailure = objectMapper.readTree("""
{"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}}
""");
var wrongEvent = objectMapper.readTree("""
{"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}}
""");
assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
200, acknowledged, "event-1")).doesNotThrowAnyException();
assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement(
200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class);
}
@Test
void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception {
String application = Files.readString(Path.of("src/main/resources/application.yml"));
assertThat(application).contains(
"LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events")
.doesNotContain("/internal/overseas-security/v1/events");
}
}
@@ -1,313 +0,0 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventTypes;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
import com.baiyue.common.web.CorrelationContext;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.slf4j.MDC;
import java.net.URI;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final com.baiyue.eventbridge.service.LastlegEventClient eventClient =
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class);
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
private SimpleMeterRegistry registry;
@BeforeEach
void setUp() {
EventDispatcherProperties properties = new EventDispatcherProperties(
URI.create("https://lastleg.internal/events"), 50, 3,
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
Duration.ofSeconds(3), Duration.ofSeconds(10));
dispatcher = new OverseasEventDispatcher();
registry = new SimpleMeterRegistry();
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient);
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
event = new BridgeInbox();
event.setEventId("event-1");
event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED);
event.setSourceRegion("CN");
event.setTargetRegion("OVERSEAS");
event.setRetryCount(0);
event.setReceivedAt(LocalDateTime.now().minusSeconds(5));
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
}
@Test
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
event.setTraceId("550e8400-e29b-41d4-a716-446655440000");
when(eventClient.deliver(event)).thenAnswer(invocation -> {
assertThat(MDC.get(CorrelationContext.TRACE_ID))
.isEqualTo("550e8400e29b41d4a716446655440000");
return "200";
});
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
verify(eventClient).deliver(event);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
assertThat(transportCount("DELIVERED")).isEqualTo(1.0);
assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1);
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
}
@Test
void failedDeliveryIsScheduledForRetry() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
assertThat(transportCount("RETRY")).isEqualTo(1.0);
}
@Test
void finalFailureIsMovedToDeadState() throws Exception {
event.setRetryCount(2);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
assertThat(transportCount("DEAD")).isEqualTo(1.0);
}
@Test
void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception {
event.setRetryCount(4);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down"));
}
@Test
void eventClaimedByAnotherInstanceIsSkipped() {
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
}
@Test
void cnTargetIsDeliveredToFirstleg() throws Exception {
event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED);
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(firstlegEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(firstlegEventClient).deliver(event);
verifyNoInteractions(eventClient);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception {
event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(financeEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(financeEventClient).deliver(event);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(eventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) {
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient);
event.setEventType(eventType);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
when(securityProjectionEventClient.deliver(event)).thenReturn("200");
dispatcher.dispatchOne("event-1");
verify(securityProjectionEventClient).deliver(event);
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
}
}
@Test
void unsupportedTargetRegionIsNeverMisroutedToLastleg() {
event.setTargetRegion("UNKNOWN");
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持")))
.thenReturn(1);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(securityProjectionEventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
}
@Test
void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() {
event.setEventType(null);
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(),
eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(securityProjectionEventClient);
BridgeDeliveryAttempt attempt = updatedAttempt();
assertThat(attempt.getStatus()).isEqualTo("RETRY");
assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者");
}
@Test
void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception {
when(eventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString());
BridgeDeliveryAttempt attempt = updatedAttempt();
assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST");
assertThat(attempt.getResponseCode()).isEqualTo("200");
assertThat(attempt.getErrorMessage()).contains("租约已失效");
assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0);
}
@Test
void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")))
.thenReturn(0);
dispatcher.dispatchOne("event-1");
assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST");
assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0);
}
@Test
void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception {
for (String target : List.of("OVERSEAS", "CN")) {
reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient);
event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN");
event.setTargetRegion(target);
event.setRetryCount(0);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2);
doAnswer(invocation -> {
event.setRetryCount(1);
event.setStatus("RETRY");
event.setLastError(invocation.getArgument(4));
event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds(
((Number) invocation.getArgument(3)).longValue()));
return 1;
}).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString());
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> {
event.setStatus("PROCESSED");
event.setLastError(null);
event.setNextRetryAt(null);
return 1;
});
if ("CN".equals(target)) {
when(firstlegEventClient.deliver(event))
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn"))
.thenReturn("200");
} else {
when(eventClient.deliver(event))
.thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas"))
.thenReturn("200");
}
dispatcher.dispatchOne("event-1");
assertThat(event.getStatus()).isEqualTo("RETRY");
assertThat(event.getNextRetryAt()).isNotNull();
assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId=");
dispatcher.dispatchOne("event-1");
ArgumentCaptor<BridgeDeliveryAttempt> attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper, times(2)).updateById(attempts.capture());
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo)
.containsExactly(1, 2);
assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus)
.containsExactly("RETRY", "DELIVERED");
assertThat(attempts.getAllValues().get(0).getErrorMessage())
.contains("HTTP 503", "requestId=", "traceId=");
assertThat(event.getStatus()).isEqualTo("PROCESSED");
assertThat(event.getLastError()).isNull();
}
}
private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
return captor.getValue();
}
private double transportCount(String result) {
return registry.get(EventBridgeMetrics.TRANSPORT_METRIC)
.tags("target_region", event.getTargetRegion(),
"event_type", event.getEventType(), "result", result)
.counter().count();
}
}
@@ -1,65 +0,0 @@
package com.baiyue.eventbridge.trace;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.eventbridge.domain.BridgeInbox;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import java.net.URI;
import java.net.http.HttpRequest;
import static org.assertj.core.api.Assertions.assertThat;
class EventBridgeTracePropagationTest {
@AfterEach
void clearMdc() {
MDC.clear();
}
@Test
void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() {
BridgeInbox event = new BridgeInbox();
event.setTraceId("550e8400-e29b-41d4-a716-446655440000");
event.setCorrelationId("ORDER:customer-99:booking-123");
event.setEventId("event-business-id");
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
String localSpan = MDC.get(CorrelationContext.SPAN_ID);
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal"))
.header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId())
.header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId())
.header(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build();
assertThat(MDC.get(CorrelationContext.TRACE_ID))
.isEqualTo("550e8400e29b41d4a716446655440000");
assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER))
.contains("550e8400e29b41d4a716446655440000");
assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow())
.matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId());
assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow())
.matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01")
.doesNotContain(localSpan);
}
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
}
@Test
void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() {
MDC.put("existing", "preserved");
BridgeInbox event = new BridgeInbox();
event.setTraceId("JWT-or-business-id-value");
try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) {
assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}")
.isNotEqualTo(event.getTraceId());
}
assertThat(MDC.get("existing")).isEqualTo("preserved");
assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull();
}
}
-37
View File
@@ -1,37 +0,0 @@
# Overseas Gateway 模块指引
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。当前入口读 `../gateway/AGENTS.md`,认证读 `../auth/AGENTS.md` 与 `../../docs/统一认证与服务通信契约.md`;退役清理按 `../../docs/测试环境单机部署与配置契约.md` 核对实际引用与运行证据。不得恢复双 Token、专用安全投影或重新加入构建。
## 职责边界
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
- 历史路由与身份实现只用于追溯;现行认证按 `../../docs/统一认证与服务通信契约.md`。
## 快速索引
| 功能 | 入口 |
|---|---|
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
| Audience 校验 | `config/AudienceJwtValidator.java` |
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
| PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed |
| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本不一致立即拒绝,并取回当前角色/权限快照 |
| 安全配置项 | `config/GatewaySecurityProperties.java` |
| 可信身份与授权头传递 | `filter/TrustedIdentityHeaderFilter.java`、`filter/OverseasSecurityProjectionFilter.java`;先清除外部伪造头,再以内部服务令牌保护投影快照 |
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
| 浏览器业务路由 | `src/main/resources/application-overseas.yml`;Lastleg 显式覆盖 `/api/merchant/v1/last-leg/**`、`/api/platform/v1/last-leg/**`,Warehouse 仅显式覆盖平台仓库资源路径 |
| 海外 PDA 路由 | 设备端 `../../apps/pda-overseas-uniapp/`,服务端入口 `/api/pda/v1/last-leg/**`;必须在 Gateway 路由中显式声明并保持海外 Token/权限校验,不得让设备直连 Lastleg |
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
## 安全规则
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
- 网关必须清除外请求伪造的可信身份与授权头,仅传递验签并核对安全投影后生成的上下文;业务服务必须验证内部服务令牌。
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
- merchant、platform 与 PDA 路径分别显式维护路由和授权边界;新增 PDA Controller 不会自动成为公网路由,Gateway 未声明前不得把设备链路标记为已联通。
## 修改与验证
- 本模块已退出聚合,不能从根使用 `mvn -pl gateway-overseas`;当前路由变更验证 `gateway` 及调用端,执行根 `scripts/gateway-route-contract.test.mjs`。
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
-69
View File
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baiyue.firstleg</groupId>
<artifactId>baiyue-firstleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>gateway-overseas</artifactId>
<name>gateway-overseas</name>
<dependencies>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -1,14 +0,0 @@
package com.baiyue.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
*/
@SpringBootApplication
public class BaiyueGatewayOverseasApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueGatewayOverseasApplication.class, args);
}
}
@@ -1,32 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import reactor.core.publisher.Mono;
/**
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
*/
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final String expectedAppCode;
AppScopeAuthorizationManager(String expectedAppCode) {
this.expectedAppCode = expectedAppCode;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
return authentication.filter(Authentication::isAuthenticated)
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
private boolean hasExpectedScope(Authentication authentication) {
return authentication instanceof JwtAuthenticationToken jwt
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
}
}
@@ -1,23 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
private final String requiredAudience;
AudienceJwtValidator(String requiredAudience) {
this.requiredAudience = requiredAudience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains(requiredAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT audience 不允许访问海外 API", null));
}
}
@@ -1,90 +0,0 @@
package com.baiyue.gateway.config;
import com.baiyue.gateway.filter.CorrelationIdWebFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
*/
@Configuration
@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class,
OverseasSecurityProjectionProperties.class})
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
GatewaySecurityProperties properties,
ObjectMapper objectMapper) {
var exchanges = http.authorizeExchange();
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
exchanges.pathMatchers("/api/merchant/**")
.access(new AppScopeAuthorizationManager("MERCHANT"));
exchanges.pathMatchers("/api/platform/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.anyExchange().authenticated();
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
.build();
}
@Bean
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(properties.jwkSetUri())
.jwsAlgorithms(algorithms -> {
algorithms.add(SignatureAlgorithm.RS256);
})
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.issuer()),
new AudienceJwtValidator(properties.audience()),
new RequiredClaimsJwtValidator()));
return decoder;
}
public static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
ObjectMapper mapper, HttpStatus status, String code, String message) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String requestId = CorrelationIdWebFilter.ensureRequestId(exchange);
String traceId = CorrelationIdWebFilter.ensureTraceId(exchange);
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId);
exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
body.put("data", null);
body.put("requestId", requestId);
body.put("traceId", traceId);
body.put("timestamp", Instant.now().toString());
try {
byte[] bytes = mapper.writeValueAsBytes(body);
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
} catch (Exception exception) {
return exchange.getResponse().setComplete();
}
}
}
@@ -1,27 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwkSetUri,
String issuer,
String audience,
List<String> permitPaths) {
public GatewaySecurityProperties {
if (jwkSetUri == null || jwkSetUri.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
}
if (issuer == null || issuer.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
}
if (audience == null || audience.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
}
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
}
@@ -1,17 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.security.overseas-projection")
public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) {
public OverseasSecurityProjectionProperties {
if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL");
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout;
serviceUrl = serviceUrl.replaceAll("/+$", "");
}
}
@@ -1,25 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外 PDA 实时会话校验配置;URL 与服务令牌只能由环境提供。 */
@ConfigurationProperties(prefix = "baiyue.security.pda-session-validation")
public record PdaSessionValidationProperties(String authUrl,
String internalServiceToken,
Duration timeout) {
public PdaSessionValidationProperties {
if (authUrl == null || authUrl.isBlank()) {
throw new IllegalArgumentException("必须配置 AUTH_SESSION_VALIDATION_URL");
}
if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
timeout = timeout == null ? Duration.ofSeconds(2) : timeout;
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("PDA_SESSION_VALIDATION_TIMEOUT 必须大于 0");
}
authUrl = authUrl.replaceAll("/+$", "");
}
}
@@ -1,62 +0,0 @@
package com.baiyue.gateway.config;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.List;
import java.util.Locale;
/** Validates the identity and authorization claims required by overseas APIs. */
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
private static final List<String> REQUIRED_CLAIMS = List.of(
"sub", "tenant_id", "app_code", "token_version", "permission_version");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
for (String claim : REQUIRED_CLAIMS) {
Object value = jwt.getClaims().get(claim);
if (value == null || value.toString().isBlank()) {
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "JWT 缺少必要声明: " + claim, null));
}
}
Long subject = longValue(jwt.getSubject());
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
Long tokenVersion = longValue(jwt.getClaims().get("token_version"));
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
if (subject == null || subject <= 0) {
return failure("JWT sub 必须是正整数用户 ID");
}
if (tenantId == null || tenantId < 0) {
return failure("JWT tenant_id 必须是非负整数客户范围");
}
if (permissionVersion == null || permissionVersion < 0) {
return failure("JWT permission_version 必须是非负整数");
}
if (tokenVersion == null || tokenVersion < 0) {
return failure("JWT token_version 必须是非负整数");
}
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
return failure("JWT app_code 与 tenant_id 访问域不一致");
}
return OAuth2TokenValidatorResult.success();
}
private static Long longValue(Object value) {
if (value instanceof Number number) return number.longValue();
if (value == null) return null;
try {
return Long.valueOf(value.toString());
} catch (NumberFormatException ignored) {
return null;
}
}
private static OAuth2TokenValidatorResult failure(String message) {
return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null));
}
}
@@ -1,52 +0,0 @@
package com.baiyue.gateway.filter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.util.UUID;
/** Ensures overseas security failures carry the same correlation contract as normal API responses. */
@Component
public class CorrelationIdWebFilter implements WebFilter, Ordered {
public static final String REQUEST_HEADER = "X-Request-Id";
public static final String TRACE_HEADER = "X-Trace-Id";
private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId";
private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange));
exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange));
return chain.filter(exchange);
}
public static String ensureRequestId(ServerWebExchange exchange) {
return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE,
ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER));
}
public static String ensureTraceId(ServerWebExchange exchange) {
return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE,
ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER));
}
private static String accepted(HttpHeaders headers, String name) {
String incoming = headers.getFirst(name);
return valid(incoming) ? incoming : UUID.randomUUID().toString();
}
private static boolean valid(String value) {
return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128
&& value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e);
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}
@@ -1,50 +0,0 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.LinkedHashSet;
import java.util.Set;
@Component
public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient {
private static final String PATH = "/internal/lastleg/security/v1/validate";
private final WebClient client;
private final OverseasSecurityProjectionProperties properties;
public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) {
this.client = builder.build();
this.properties = properties;
}
@Override
public Mono<Result> validate(long userId, long tenantId, long permissionVersion) {
return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON)
.header("X-Internal-Service-Token", properties.internalServiceToken())
.bodyValue(new Request(userId, tenantId, permissionVersion))
.retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> {
JsonNode data = body == null ? null : body.path("data");
if (data == null || data.isMissingNode() || !data.has("valid")) {
return Mono.error(new IllegalStateException("海外安全投影响应无效"));
}
return Mono.just(new Result(data.path("valid").asBoolean(false),
data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null),
strings(data.path("roles")), strings(data.path("authorities"))));
});
}
private static Set<String> strings(JsonNode values) {
if (values == null || !values.isArray()) return Set.of();
LinkedHashSet<String> result = new LinkedHashSet<>();
values.forEach(value -> {
String text = value.asText("");
if (!text.isBlank()) result.add(text);
});
return Set.copyOf(result);
}
private record Request(long userId, long tenantId, long permissionVersion) { }
}
@@ -1,46 +0,0 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.PdaSessionValidationProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
/** 调用 Auth 内部实时校验端点;任何传输或响应异常均交由 Gateway fail-closed。 */
@Component
public class HttpPdaSessionValidationClient implements PdaSessionValidationClient {
private static final String PATH = "/internal/auth/accounts/overseas-session/validate";
private final WebClient webClient;
private final PdaSessionValidationProperties properties;
public HttpPdaSessionValidationClient(WebClient.Builder builder, PdaSessionValidationProperties properties) {
this.webClient = builder.build();
this.properties = properties;
}
@Override
public Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId) {
return webClient.post()
.uri(properties.authUrl() + PATH)
.contentType(MediaType.APPLICATION_JSON)
.header("X-Internal-Service-Token", properties.internalServiceToken())
.bodyValue(new Request(userId, tokenVersion, overseasWarehouseId))
.retrieve()
.bodyToMono(JsonNode.class)
.timeout(properties.timeout())
.flatMap(this::decode);
}
private Mono<Result> decode(JsonNode envelope) {
JsonNode data = envelope == null ? null : envelope.path("data");
if (data == null || data.isMissingNode() || data.isNull() || !data.has("valid")) {
return Mono.error(new IllegalStateException("Auth overseas session validation response is invalid"));
}
boolean valid = data.path("valid").asBoolean(false);
String reason = data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null);
return Mono.just(new Result(valid, reason));
}
private record Request(long userId, int tokenVersion, long overseasWarehouseId) { }
}
@@ -1,90 +0,0 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.GatewaySecurityConfig;
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.stream.Collectors;
@Component
public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered {
private final OverseasSecurityValidationClient client;
private final ObjectMapper objectMapper;
private final OverseasSecurityProjectionProperties properties;
public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper,
OverseasSecurityProjectionProperties properties) {
this.client = client;
this.objectMapper = objectMapper;
this.properties = properties;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange);
return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated)
.ofType(JwtAuthenticationToken.class)
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效")))
.flatMap(authentication -> validate(exchange, chain, authentication))
.onErrorResume(SecurityProjectionFailure.class,
failure -> write(exchange, failure.code, failure.getMessage()))
.onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用"));
}
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
JwtAuthenticationToken authentication) {
Long userId = number(authentication.getToken().getSubject());
Long tenantId = number(authentication.getToken().getClaim("tenant_id"));
Long permissionVersion = number(authentication.getToken().getClaim("permission_version"));
if (userId == null || tenantId == null || permissionVersion == null) {
return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失"));
}
return client.validate(userId, tenantId, permissionVersion)
.switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用")))
.flatMap(result -> {
if (result.valid()) {
var request = exchange.getRequest().mutate()
.header("X-Overseas-Roles", join(result.roles()))
.header("X-Overseas-Authorities", join(result.authorities()))
.header("X-Internal-Service-Token", properties.internalServiceToken())
.build();
return chain.filter(exchange.mutate().request(request).build());
}
if ("USER_DISABLED".equals(result.failureReason())) {
return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用"));
}
return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化"));
});
}
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
return GatewaySecurityConfig.writeError(exchange, objectMapper,
"A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message);
}
private static Long number(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException exception) { return null; }
}
private static String join(Collection<String> values) {
return values.stream().sorted().collect(Collectors.joining(","));
}
@Override public int getOrder() { return -95; }
private static final class SecurityProjectionFailure extends RuntimeException {
private final String code;
private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; }
}
}
@@ -1,14 +0,0 @@
package com.baiyue.gateway.filter;
import reactor.core.publisher.Mono;
import java.util.Set;
public interface OverseasSecurityValidationClient {
Mono<Result> validate(long userId, long tenantId, long permissionVersion);
record Result(boolean valid, String failureReason, Set<String> roles, Set<String> authorities) {
public Result {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
}
@@ -1,10 +0,0 @@
package com.baiyue.gateway.filter;
import reactor.core.publisher.Mono;
/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */
public interface PdaSessionValidationClient {
Mono<Result> validate(long userId, int tokenVersion, long overseasWarehouseId);
record Result(boolean valid, String failureReason) { }
}
@@ -1,92 +0,0 @@
package com.baiyue.gateway.filter;
import com.baiyue.gateway.config.GatewaySecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/** 仅对海外 PDA API 执行无缓存的账号版本与仓绑定实时校验。 */
@Component
public class PdaSessionValidationFilter implements GlobalFilter, Ordered {
private static final String PDA_PREFIX = "/api/pda/";
private final PdaSessionValidationClient client;
private final ObjectMapper objectMapper;
public PdaSessionValidationFilter(PdaSessionValidationClient client, ObjectMapper objectMapper) {
this.client = client;
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!exchange.getRequest().getURI().getPath().startsWith(PDA_PREFIX)) {
return chain.filter(exchange);
}
return exchange.getPrincipal().cast(Authentication.class)
.filter(Authentication::isAuthenticated)
.ofType(JwtAuthenticationToken.class)
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "未登录或令牌已失效")))
.flatMap(authentication -> validate(exchange, chain, authentication))
.onErrorResume(InvalidPdaSession.class, failure -> write(exchange, failure.code, failure.getMessage()))
.onErrorResume(failure -> write(exchange, "A0401", "海外PDA会话校验不可用"));
}
private Mono<Void> validate(ServerWebExchange exchange, GatewayFilterChain chain,
JwtAuthenticationToken authentication) {
Long userId = longValue(authentication.getToken().getSubject());
Integer tokenVersion = intClaim(authentication, "token_version");
Long warehouseId = longClaim(authentication, "overseas_warehouse_id");
if (userId == null || tokenVersion == null) {
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌版本声明缺失"));
}
if (warehouseId == null) {
return Mono.error(new InvalidPdaSession("A0403", "海外PDA账号未绑定仓库"));
}
return client.validate(userId, tokenVersion, warehouseId)
.switchIfEmpty(Mono.error(new InvalidPdaSession("A0401", "海外PDA会话校验不可用")))
.flatMap(result -> {
if (result.valid()) return chain.filter(exchange);
if ("WAREHOUSE_UNBOUND".equals(result.failureReason())
|| "WAREHOUSE_MISMATCH".equals(result.failureReason())) {
return Mono.error(new InvalidPdaSession("A0403", "海外PDA仓库绑定已变化"));
}
return Mono.error(new InvalidPdaSession("A0401", "海外PDA令牌已失效"));
});
}
private Mono<Void> write(ServerWebExchange exchange, String code, String message) {
HttpStatus status = "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
return GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, message);
}
private static Integer intClaim(JwtAuthenticationToken authentication, String name) {
Object value = authentication.getToken().getClaims().get(name);
if (value instanceof Number number) return number.intValue();
try { return value == null ? null : Integer.valueOf(value.toString()); }
catch (NumberFormatException ignored) { return null; }
}
private static Long longClaim(JwtAuthenticationToken authentication, String name) {
return longValue(authentication.getToken().getClaims().get(name));
}
private static Long longValue(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException ignored) { return null; }
}
@Override public int getOrder() { return -90; }
private static final class InvalidPdaSession extends RuntimeException {
private final String code;
private InvalidPdaSession(String code, String message) { super(message); this.code = code; }
}
}
@@ -1,73 +0,0 @@
package com.baiyue.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
*/
@Component
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
private static final List<String> TRUSTED_HEADERS = List.of(
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
"X-App-Code", "X-Client-Type", "X-Overseas-Roles", "X-Overseas-Authorities",
"X-Internal-Service-Token"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getPrincipal()
.cast(Authentication.class)
.defaultIfEmpty(new AnonymousAuthentication())
.flatMap(authentication -> {
var requestBuilder = exchange.getRequest().mutate();
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
}
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
});
}
private static String claim(JwtAuthenticationToken jwt, String name) {
Object value = jwt.getToken().getClaims().get(name);
return value == null ? null : value.toString();
}
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
String name,
String value) {
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
@Override
public int getOrder() {
return -100;
}
private static final class AnonymousAuthentication implements Authentication {
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
@Override public Object getCredentials() { return ""; }
@Override public Object getDetails() { return null; }
@Override public Object getPrincipal() { return "anonymous"; }
@Override public boolean isAuthenticated() { return false; }
@Override public void setAuthenticated(boolean isAuthenticated) { }
@Override public String getName() { return "anonymous"; }
}
}
@@ -1,72 +0,0 @@
spring:
config:
activate:
on-profile: overseas
cloud:
gateway:
discovery:
locator:
enabled: false
routes:
- id: lastleg-service
uri: lb://lastleg-service
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/**"]
- id: warehouse-overseas-service
uri: lb://warehouse-overseas-service
predicates: ["Path=/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
- id: file-service
uri: lb://file-service
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
- id: print-service
uri: lb://print-service
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
- id: lastleg-openapi
uri: lb://lastleg-service
predicates: [Path=/lastleg-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: warehouse-openapi
uri: lb://warehouse-overseas-service
predicates: [Path=/warehouse-overseas-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: file-openapi
uri: lb://file-service
predicates: [Path=/file-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: print-openapi
uri: lb://print-service
predicates: [Path=/print-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: audit-openapi
uri: lb://audit-service
predicates: [Path=/audit-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: integration-openapi
uri: lb://integration-service
predicates: [Path=/integration-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: event-bridge-openapi
uri: lb://event-bridge-service
predicates: [Path=/event-bridge-service/v3/api-docs/**]
filters: [StripPrefix=1]
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
springdoc:
swagger-ui:
urls:
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
- { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda }
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
- { name: file-platform, url: /file-service/v3/api-docs/platform }
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
- { name: print-platform, url: /print-service/v3/api-docs/platform }
- { name: print-merchant, url: /print-service/v3/api-docs/merchant }
- { name: audit, url: /audit-service/v3/api-docs }
- { name: integration, url: /integration-service/v3/api-docs }
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
@@ -1,78 +0,0 @@
server:
port: ${GATEWAY_PORT:9300}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: gateway-overseas
profiles:
default: overseas
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
enabled: ${NACOS_CONFIG_ENABLED:true}
namespace: ${NACOS_NAMESPACE:public}
gateway:
discovery:
locator:
enabled: false
baiyue:
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
overseas-projection:
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
permit-paths:
- /actuator/health
- /swagger-ui/**
- /webjars/**
- /v3/api-docs/**
- /lastleg-service/v3/api-docs/**
- /warehouse-overseas-service/v3/api-docs/**
- /file-service/v3/api-docs/**
- /print-service/v3/api-docs/**
- /audit-service/v3/api-docs/**
- /integration-service/v3/api-docs/**
- /event-bridge-service/v3/api-docs/**
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -1,55 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
class AppScopeAuthorizationManagerTest {
@Test
void acceptsMatchingApplicationScope() {
var manager = new AppScopeAuthorizationManager("MERCHANT");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> decision.isGranted())
.verifyComplete();
}
@Test
void rejectsDifferentApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
@Test
void rejectsMissingApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.build();
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
private JwtAuthenticationToken authentication(String appCode) {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("app_code", appCode)
.build();
return new JwtAuthenticationToken(jwt,
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
}
}
@@ -1,33 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AudienceJwtValidatorTest {
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
@Test
void acceptsTokenForLastlegApi() {
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
}
@Test
void rejectsTokenForAnotherApi() {
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
}
private Jwt jwt(List<String> audiences) {
return Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.audience(audiences)
.build();
}
}
@@ -1,30 +0,0 @@
package com.baiyue.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class GatewaySecurityErrorContractTest {
@Test
void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception {
var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test")
.header("X-Request-Id", "request-1234567890")
.header("X-Trace-Id", "trace-123456789012")
.build());
GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED,
"A0401", "未登录或令牌已失效").block();
var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block());
assertEquals("request-1234567890", body.path("requestId").asText());
assertEquals("trace-123456789012", body.path("traceId").asText());
assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id"));
assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id"));
assertFalse(body.path("timestamp").asText().isBlank());
}
}
@@ -1,57 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasLastlegRouteContractTest {
@Test
void browserAndPdaPathsAreForwardedToLastlegWithoutPrefixRewrite() throws IOException {
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
int routeStart = yaml.indexOf("- id: lastleg-service");
int routeEnd = yaml.indexOf("- id: warehouse-overseas-service", routeStart);
assertThat(routeStart).isGreaterThanOrEqualTo(0);
assertThat(routeEnd).isGreaterThan(routeStart);
String route = yaml.substring(routeStart, routeEnd);
assertThat(route).contains("uri: lb://lastleg-service")
.contains("/api/platform/v1/last-leg/**")
.contains("/api/merchant/v1/last-leg/**")
.contains("/api/pda/v1/last-leg/**")
.doesNotContain("StripPrefix", "RewritePath");
}
}
@Test
void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException {
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda")
.contains("Path=/lastleg-service/v3/api-docs/**")
.doesNotContain("event-bridge-service/v3/api-docs/pda");
}
}
@Test
void lastlegPdaRouteCoversFrozenDispatchAndOutboundTaskPaths() throws IOException {
try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) {
assertThat(input).isNotNull();
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
assertThat(yaml).contains("/api/pda/v1/last-leg/**");
for (String path : new String[] {
"/api/pda/v1/last-leg/dispatches/DSP-1",
"/api/pda/v1/last-leg/dispatches/DSP-1/depart",
"/api/pda/v1/last-leg/outbound-tasks/PDA-1/complete"
}) {
assertThat(path).startsWith("/api/pda/v1/last-leg/");
}
}
}
}
@@ -1,84 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasSecurityValidationDeploymentContractTest {
private static final String HEADER_SIZE =
"max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}";
@Test
void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception {
String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml");
String lastlegApplication = read("lastleg-service/src/main/resources/application.yml");
String localGateway = read("config/nacos/local/gateway-overseas.yml");
String localLastleg = read("config/nacos/local/lastleg-service.yml");
String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml");
String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml");
String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml");
String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml");
for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) {
assertThat(source)
.contains("${JWT_ISSUER:auth-service}")
.contains("${JWT_AUDIENCE:lastleg-api}")
.contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}");
}
assertThat(gatewayApplication).doesNotContain("auth.example.com");
assertThat(localGateway)
.contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}")
.contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}");
assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}");
assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}");
assertThat(remoteGateway + remoteLastleg + remoteWarehouse)
.doesNotContain("127.0.0.1", "root", "123456");
assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}");
assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}");
assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}");
}
@Test
void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception {
String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java");
String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml");
String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml");
String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml");
String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml");
String compose = read("deploy/docker-compose.overseas.yml");
assertThat(client).contains("/internal/lastleg/security/v1/validate")
.contains("new Request(userId, tenantId, permissionVersion)")
.doesNotContain("new Request(userId, tenantId, tokenVersion")
.doesNotContain("/internal/overseas-security/");
for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) {
assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303");
}
for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) {
assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL",
"/internal/lastleg/security/v1/events");
}
assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose)
.doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL",
"OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL");
}
@Test
void overseasAuthorizationSnapshotHeadersFitEveryServletService() throws Exception {
assertThat(read("config/nacos/overseas/baiyue-common.yml")).contains(HEADER_SIZE);
assertThat(read("lastleg-service/src/main/resources/application.yml")).contains(HEADER_SIZE);
assertThat(read("warehouse-overseas-service/src/main/resources/application.yml")).contains(HEADER_SIZE);
assertThat(read("deploy/docker-compose.overseas.yml"))
.contains("SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}");
}
private static String read(String relative) throws Exception {
Path path = Path.of(relative);
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -1,43 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSessionValidationDeploymentContractTest {
@Test
void nacosComposeAndJenkinsDeploymentRequireOnlyEnvironmentBackedAuthCoordinates() throws Exception {
String application = read("gateway-overseas/src/main/resources/application.yml");
String nacos = read("config/nacos/overseas/baiyue-common.yml");
String compose = read("deploy/docker-compose.overseas.yml");
String deployServices = read("deploy/scripts/deploy-services.sh");
String deployBuiltService = read("deploy/scripts/deploy-built-service.sh");
String jenkins = read("Jenkinsfile");
for (String source : new String[]{application, nacos, compose}) {
assertThat(source).contains("AUTH_SESSION_VALIDATION_URL", "INTERNAL_SERVICE_TOKEN",
"PDA_SESSION_VALIDATION_TIMEOUT");
}
assertThat(deployServices).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
assertThat(deployBuiltService).contains("gateway-overseas", "AUTH_SESSION_VALIDATION_URL");
assertThat(jenkins).contains("'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'", "'auth': 'SERVICE_AUTH'",
"'iam': 'SERVICE_IAM'");
assertThat(application).doesNotContain("/internal/auth/accounts/overseas-session/validate");
assertThat(read("gateway-overseas/src/main/resources/application-overseas.yml"))
.doesNotContain("/internal/auth/**", "/internal/auth/accounts/overseas-session/validate");
assertThat(read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java"))
.contains("new Request(userId, tokenVersion, overseasWarehouseId)",
".header(\"X-Internal-Service-Token\"")
.doesNotContain("Authorization", "accessToken", "rawToken", "getTokenValue");
}
private static String read(String relative) throws Exception {
Path path = Path.of(relative);
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -1,94 +0,0 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RequiredClaimsJwtValidatorTest {
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
@Test
void acceptsCompleteIdentityClaims() {
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
}
@Test
void rejectsMissingPermissionVersion() {
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
}
@Test
void rejectsMissingAccountTokenVersion() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM")
.claim("permission_version", 3L)
.build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
@Test
void acceptsTokenWithoutAuthoritiesBecauseProjectionOwnsAuthorization() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("token_version", 1)
.claim("permission_version", 3L)
.build();
assertThat(validator.validate(value).hasErrors()).isFalse();
}
@Test
void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() {
assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse();
}
@Test
void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() {
assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors())
.isFalse();
}
@Test
void rejectsApplicationAndCustomerScopeMismatch() {
assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors())
.isTrue();
assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors())
.isTrue();
}
@Test
void rejectsMalformedIdentityAndPermissionVersion() {
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
.claim("token_version", 1)
.claim("authorities", List.of()).build();
assertThat(validator.validate(value).hasErrors()).isTrue();
}
private Jwt jwt(boolean includePermissionVersion) {
return jwt("0", "PLATFORM",
List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion);
}
private Jwt jwt(String tenantId, String appCode, List<String> authorities,
boolean includePermissionVersion) {
Jwt.Builder builder = Jwt.withTokenValue("token")
.header("alg", "RS256")
.subject("1001")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", tenantId)
.claim("app_code", appCode)
.claim("token_version", 1)
.claim("authorities", authorities);
if (includePermissionVersion) {
builder.claim("permission_version", 3L);
}
return builder.build();
}
}
@@ -1,97 +0,0 @@
package com.baiyue.gateway.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.gateway.config.OverseasSecurityProjectionProperties;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.time.Duration;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class OverseasSecurityProjectionFilterTest {
private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class);
private final OverseasSecurityProjectionProperties properties =
new OverseasSecurityProjectionProperties("http://lastleg",
"gateway-internal-test-token-32-bytes", Duration.ofSeconds(2));
private final OverseasSecurityProjectionFilter filter =
new OverseasSecurityProjectionFilter(client, new ObjectMapper(), properties);
@Test void disabledUserFailsWithA0401() {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(false, "USER_DISABLED", Set.of(), Set.of())));
var exchange = exchange("/api/platform/v1/last-leg/orders");
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(body(exchange)).contains("A0401");
}
@Test void disabledTenantAndStalePermissionFailWithA0403() {
for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(false, reason, Set.of(), Set.of())));
var exchange = exchange("/api/merchant/v1/last-leg/orders");
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(body(exchange)).contains("A0403");
}
}
@Test void projectionUnavailableFailsClosed() {
when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down")));
var exchange = exchange("/api/platform/v1/last-leg/orders");
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() {
when(client.validate(42, 0, 8)).thenReturn(Mono.just(
new OverseasSecurityValidationClient.Result(true, null, Set.of("PLATFORM_ADMIN"),
Set.of("platform:overseas-warehouse:last-mile-order:list"))));
AtomicBoolean routed = new AtomicBoolean();
var accepted = exchange("/api/platform/v1/last-leg/orders");
StepVerifier.create(filter.filter(accepted, routedExchange -> {
routed.set(true);
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Internal-Service-Token"))
.isEqualTo("gateway-secret");
assertThat(routedExchange.getRequest().getHeaders().getFirst("X-Overseas-Authorities"))
.isEqualTo("platform:overseas-warehouse:last-mile-order:list");
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
var isolatedClient = mock(OverseasSecurityValidationClient.class);
var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper(), properties);
StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete();
verifyNoInteractions(isolatedClient);
}
private static ServerWebExchange exchange(String path) {
Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build();
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build());
return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt,
List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build();
}
private static String body(ServerWebExchange exchange) {
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
.getBodyAsString().block();
}
}
@@ -1,119 +0,0 @@
package com.baiyue.gateway.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class PdaSessionValidationFilterTest {
private final PdaSessionValidationClient client = mock(PdaSessionValidationClient.class);
private final PdaSessionValidationFilter filter = new PdaSessionValidationFilter(client, new ObjectMapper());
@Test
void oldTokenIsRejectedImmediatelyAfterWarehouseChange() {
when(client.validate(42L, 7, 900L)).thenReturn(Mono.just(
new PdaSessionValidationClient.Result(false, "TOKEN_VERSION_MISMATCH")));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(7, 900L));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(body(exchange)).contains("A0401", "令牌已失效");
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(client.validate(42L, 8, 901L)).thenReturn(Mono.just(
new PdaSessionValidationClient.Result(true, null)));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
AtomicBoolean routed = new AtomicBoolean();
StepVerifier.create(filter.filter(exchange, ignored -> {
routed.set(true);
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
assertThat(exchange.getResponse().getStatusCode()).isNull();
}
@Test
void unboundWarehouseIsDeniedWithoutCallingAuth() {
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, null));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(body(exchange)).contains("A0403", "未绑定仓库");
verifyNoInteractions(client);
}
@Test
void authUnavailableFailsClosed() {
when(client.validate(42L, 8, 901L)).thenReturn(Mono.error(new IllegalStateException("timeout")));
var exchange = exchange("/api/pda/v1/last-leg/receiving/containers", claims(8, 901L));
StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete();
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(body(exchange)).contains("A0401", "校验不可用");
}
@Test
void ordinaryOverseasAdminAndClientPathsDoNotSynchronouslyValidate() {
for (String path : new String[]{"/api/platform/v1/last-leg/orders", "/api/merchant/v1/last-leg/orders"}) {
var exchange = exchange(path, claims(7, 900L));
AtomicBoolean routed = new AtomicBoolean();
StepVerifier.create(filter.filter(exchange, ignored -> {
routed.set(true);
return Mono.empty();
})).verifyComplete();
assertThat(routed).isTrue();
}
verifyNoInteractions(client);
}
private static ServerWebExchange exchange(String path, Map<String, Object> claims) {
Jwt.Builder jwt = Jwt.withTokenValue("already-verified-by-resource-server")
.header("alg", "RS256")
.subject("42")
.issuedAt(Instant.now().minusSeconds(5))
.expiresAt(Instant.now().plusSeconds(300));
claims.forEach(jwt::claim);
MockServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.get(path).build());
return exchange.mutate()
.principal(Mono.just(new JwtAuthenticationToken(jwt.build(),
List.of(new SimpleGrantedAuthority("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")))))
.build();
}
private static Map<String, Object> claims(int tokenVersion, Long warehouseId) {
var claims = new java.util.LinkedHashMap<String, Object>();
claims.put("token_version", tokenVersion);
if (warehouseId != null) claims.put("overseas_warehouse_id", warehouseId);
return claims;
}
private static String body(ServerWebExchange exchange) {
return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse())
.getBodyAsString().block();
}
}
+2 -1
View File
@@ -172,7 +172,8 @@ baiyue:
allow-credentials: ${CORS_ALLOW_CREDENTIALS:false}
max-age: ${CORS_MAX_AGE:3600}
security:
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
session-validation-uri: ${SESSION_VALIDATION_URI:http://127.0.0.1:9100/internal/auth/accounts/sessions/validate}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
+17 -2
View File
@@ -5,10 +5,20 @@ spring:
active: ${SPRING_PROFILES_ACTIVE:test}
application:
name: iam-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
config:
import:
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR}
@@ -36,4 +46,9 @@ baiyue:
url: ${AUTH_SERVICE_URL:}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWKS_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
session-mode: ${SESSION_MODE:redis}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
management.endpoints.web.exposure.include: health,info,prometheus
@@ -22,7 +22,7 @@ VALUES
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,0),
(5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,0),
(5107004,0,'PLATFORM','BUTTON',5107130,'platform:overseas-warehouse:discard:approve','审核货物废弃申请',NULL,'POST',831,'ACTIVE',1,0),
(5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,0),
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
@@ -41,6 +41,7 @@ VALUES
(5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0),
(5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0),
(5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0),
(5107044,0,'PLATFORM','BUTTON',5107040,'lastleg:inventory:manage','后台退货库存管理',NULL,'POST',744,'ACTIVE',1,0),
(5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0),
(5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0),
(5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0),
@@ -65,6 +66,7 @@ VALUES
(5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0),
(5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0),
(5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0),
(5107130,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:discard:list','废弃单','/overseas-warehouse/cargo-discards','GET',830,'ACTIVE',1,0),
(5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0),
(5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0),
(5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0),
@@ -284,6 +286,26 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- 司机与车队正式写权限仅授予平台超级管理员,供退货换标真实排车使用。
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-master-data:driver:list',
'platform:overseas-master-data:driver:create',
'platform:overseas-master-data:driver:edit',
'platform:overseas-master-data:driver:upload',
'platform:overseas-master-data:driver:review',
'platform:fleet:trip:list',
'platform:fleet:trip:create',
'platform:fleet:trip:assign',
'platform:fleet:trip:edit',
'platform:fleet:trip:dispatch-review',
'platform:fleet:trip:loading-review')
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- 客户默认管理员获得当前客户海外业务入口;其他客户角色按企业实际分工授权。
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
@@ -43,6 +43,7 @@ class OverseasPhase4PermissionSqlContractTest {
"platform:overseas-warehouse:inbound-order:smart-dispatch");
private static final List<String> REQUIRED = List.of(
"platform:overseas-warehouse:last-mile-order:list",
"platform:overseas-warehouse:discard:list",
"platform:overseas-warehouse:warehouse-transfer:approve",
"platform:overseas-warehouse:inbound-order:list",
"platform:overseas-warehouse:inbound-order:fba-appointment",
@@ -75,7 +76,7 @@ class OverseasPhase4PermissionSqlContractTest {
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(sql).contains("'lastleg:pda:operate'");
assertPdaPickerGrant(sql);
assertThat(catalog).hasSize(69).noneMatch(code -> code.endsWith(":view"));
assertThat(catalog).hasSize(70).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains(
"(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'");
assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()",
@@ -18,6 +18,7 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LastlegOrderCommandService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
@@ -50,6 +51,7 @@ import java.util.List;
@RestController
@Tag(name = "Tail-leg orders")
public class LastlegOrderController {
@Resource private ReturnReceiptService returnReceiptService;
@Resource private LoadingCompletedConsumer consumer;
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService;
@@ -165,7 +167,9 @@ public class LastlegOrderController {
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(platformScope(user, customerId), id));
String scopedCustomerId = platformScope(user, customerId);
returnReceiptService.ensureTailCargo(scopedCustomerId, id);
return ApiResponse.success(orderService.detail(scopedCustomerId, id));
}
@PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review")
@@ -78,7 +78,18 @@ public final class DeliveryRequests {
@NotBlank @Size(max=128) String returnNo,
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
@Size(max=1000) String remark,
@NotBlank @Size(max=255) String idempotencyKey) {}
@NotBlank @Size(max=255) String idempotencyKey,
@Size(max=128) String skuCode,
@jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType) {
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null);
}
public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
List<String> cartonMarks, String remark, String idempotencyKey, String skuCode) {
this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, null);
}
}
public record WarehouseTransferOutbound(
@NotBlank @Size(max=64) String transferNo,
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
@@ -102,9 +113,14 @@ public final class DeliveryRequests {
}
public record PdaPick(@NotNull Long outboundPalletItemId, Long inventoryPositionId,
@NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey,
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks) {
@Size(max=500) List<@NotBlank @Size(max=255) String> cartonMarks,
@Size(max=128) String skuCode) {
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages, String idempotencyKey) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of());
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, List.of(), null);
}
public PdaPick(Long outboundPalletItemId, Long inventoryPositionId, Long packages,
String idempotencyKey, List<String> cartonMarks) {
this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, null);
}
}
public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {}
@@ -41,7 +41,15 @@ public final class DeliveryResponses {
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
String skuCode, String productName, int packageCount,
BigDecimal weightKg, BigDecimal volumeCbm) {}
BigDecimal weightKg, BigDecimal volumeCbm, Long productQuantity) {
public DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
String skuCode, String productName, int packageCount,
BigDecimal weightKg, BigDecimal volumeCbm) {
this(inboundCode, customerName, destinationWarehouseCode, receivingAddress, shipmentNo,
fbaTrackingNo, skuCode, productName, packageCount, weightKg, volumeCbm, null);
}
}
public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency,
BigDecimal amount, String remark, String reviewStatus, String sourceStatus, String voidStatus) {}
@@ -65,7 +73,7 @@ public final class DeliveryResponses {
String skuCode, String productName, long availablePackages,
BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {}
public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId,
String cartonMark, String fbaCartonMark,
String cartonMark, String palletCode, String fbaCartonMark,
String destinationWarehouseCode, String destinationWarehouseName,
long totalQuantity, String status) {}
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
@@ -78,8 +86,7 @@ public final class DeliveryResponses {
Long palletId, Long warehousePalletId, String palletNo,
int packageCount, BigDecimal grossWeightKg,
BigDecimal volumeCbm) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, String outboundType,
LocalDateTime pushedAt, LocalDateTime completedAt) {}
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt, String outboundType) {}
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
String skuCode, String inboundCode, long availablePackages,
@@ -1,9 +1,5 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -114,6 +110,7 @@ public class DeliveryTransferService {
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@Resource private InboundPlanPalletMapper inboundPlanPallets;
@Resource private WarehouseResourceGateway warehouseResources;
@@ -122,31 +119,11 @@ public class DeliveryTransferService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private LastlegOrderStatusHistoryMapper histories;
@Resource private LastlegOrderDetailMapper tailOrderDetails;
@Resource private com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper dispatchPickOrders;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelApplicationService relabelService;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo));
if (dispatch == null) throw notFound("Dispatch order not found");
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
}
@Transactional
public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId,
@@ -159,8 +136,6 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId,
DeliveryRequests.Transfer request) {
if (request.feeCoverage() != null && !request.feeCoverage().isEmpty())
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认");
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
if (found == null) throw notFound("Dispatch order not found");
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
@@ -171,20 +146,16 @@ public class DeliveryTransferService {
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
String requestHash = transferRequestHash(request);
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (replay != null) {
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
return replayTransfer(replay, requestHash);
return result(replay, requireDeliveries(customerId, replay.getId()), true);
}
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (existing != null) return replayTransfer(existing, requestHash);
if (existing != null) return result(existing, requireDeliveries(customerId, existing.getId()), true);
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
throw conflict("Dispatch cannot be transferred to delivery in its current status");
}
fleetBindings.requireMatchingAssignment(dispatch.getId(),
new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()),
new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone()));
dispatchItems.bindReceivedPallets(customerId, dispatch.getId());
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
@@ -192,16 +163,7 @@ public class DeliveryTransferService {
throw conflict("All dispatch pallets must be received before transfer to delivery");
}
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
var freight = externalFreight(dispatch, items);
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
validateTransferFees(request);
LocalDateTime now = LocalDateTime.now();
OutboundOrder outbound = new OutboundOrder();
@@ -212,15 +174,10 @@ public class DeliveryTransferService {
outbound.setLxCodes(resolveLxCodes(customerId, items));
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
outbound.setPalletCount(items.size());
outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg));
outbound.setGrossWeightKg(sum(items, DispatchItem::getGrossWeightKg));
outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg));
outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm));
outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
if (coverage.evidence() != null) {
try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); }
catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); }
}
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
try {
outboundOrders.insert(outbound);
@@ -229,11 +186,11 @@ public class DeliveryTransferService {
// the document created by the winning transaction rather than receive a false failure.
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
return replayTransfer(concurrent, requestHash);
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
}
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
if (concurrent != null) {
return replayTransfer(concurrent, requestHash);
return result(concurrent, requireDeliveries(customerId, concurrent.getId()), true);
}
throw conflict("Dispatch was concurrently transferred to delivery");
}
@@ -294,57 +251,25 @@ public class DeliveryTransferService {
delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
delivery.setPalletCount(groupItems.size());
delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm));
delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setTotalWeightKg(sum(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark()));
delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId);
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now);
bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
deliveries.add(delivery);
}
for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
// An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee.
if (quote.amount().signum() == 0) continue;
feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT",
new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(),
null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()),
"DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null,
null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(),
quote.quantityUnit(), quote.unitPrice(), null, null));
}
DeliveryOrder feeDelivery = deliveries.get(0);
LastlegOrder firstOrder = firstTailOrder(customerId, items);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet());
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(),
feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.feeType()),
new OverseasFeeSourceDtos.SaveRequest(customerId, feeDelivery.getCustomerName(),
"OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDelivery.getId(), feeDelivery.getDeliveryNo(),
firstOrder == null ? null : firstOrder.getId(), null,
firstOrder == null ? null : firstOrder.getInboundCode(), null,
firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()),
feeCode, input.feeName().trim(), input.direction(), input.supplierId(),
firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDelivery.getOverseasWarehouseId()),
input.feeType().trim(), input.feeName().trim(), input.direction(), input.supplierId(),
input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(),
input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(),
input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(),
@@ -353,45 +278,27 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false);
}
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true);
}
String transferRequestHash(DeliveryRequests.Transfer request) {
// A new command key cannot change an already transferred dispatch's business content.
com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request);
content.remove("idempotencyKey");
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) {
content.remove("feeCoverage"); content.remove("feeCoverageCargoHash");
}
return hash(content);
}
private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set<String> covered) {
private void validateTransferFees(DeliveryRequests.Transfer request) {
List<DeliveryRequests.FeeItem> inputs = request.fees() == null ? List.of() : request.fees();
if (inputs.isEmpty()) throw conflict("Delivery fee is required");
Set<String> allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE");
Set<String> seen = new HashSet<>();
for (DeliveryRequests.FeeItem input : inputs) {
String feeType = input.feeType().trim().toUpperCase(Locale.ROOT);
if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType);
if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收");
if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType);
if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT)))
throw conflict("Delivery fees only support CNY or USD");
if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero");
}
if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费");
if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim())
&& "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收");
if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required");
if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT))
&& !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) {
&& !seen.contains("LOADING_FEE")) {
throw conflict("Loading fee is required for floor or mixed loading");
}
if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) {
if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment");
if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment");
}
}
@@ -475,6 +382,10 @@ public class DeliveryTransferService {
}
}
String transferRequestHash(DeliveryRequests.Transfer request) {
return hash(request);
}
public List<DeliveryResponses.OneClickCandidate> oneClickCandidates(String customerId, String warehouseId) {
return dropshipProfiles.listOutboundCandidates(customerId,
StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream()
@@ -486,45 +397,96 @@ public class DeliveryTransferService {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
.map(c -> new DeliveryResponses.ReturnOutboundCandidate(c.getSourceReturnNo(), c.getOverseasWarehouseId(),
c.getCartonMark(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
c.getCartonMark(), c.getPalletCode(), c.getFbaCartonMark(), c.getDestinationWarehouseCode(), c.getDestinationWarehouseName(),
value(c.getTotalQuantity()), c.getStatus())).toList();
}
/** Overseas gateway freight was removed; callers must provide delivery fees explicitly. */
@Transactional(readOnly = true)
public com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview externalFreight(String dispatchNo) {
return new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview(
"", true, List.of(), List.of());
}
@Transactional
public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId,
DeliveryRequests.ReturnOutbound request) {
String requestHash = hash(request);
OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
String warehouseId = request.overseasWarehouseId().trim();
List<String> marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList();
if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复");
LocalDateTime now = LocalDateTime.now();
List<ReturnInventoryCarton> locked = new ArrayList<>();
for (String mark : marks) {
for (String mark : marks.stream().sorted().toList()) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId())
|| !"AVAILABLE".equals(carton.getStatus()) || !Objects.equals(request.returnNo().trim(), carton.getSourceReturnNo())) {
|| !returnStock.matchesReturnSource(carton, request.returnNo())) {
throw conflict("所选退货箱唛不可出库或不属于该退货单:" + mark);
}
locked.add(carton);
}
existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
if (existing != null) return replayReturnOutbound(customerId, existing, requestHash);
for (ReturnInventoryCarton carton : locked) {
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark());
var contents = returnCartonItems.lockCarton(carton.getId());
if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0))
throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark());
if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1
|| !request.skuCode().trim().equals(contents.get(0).getSkuCode())))
throw conflict("单SKU出库只支持指定SKU的散件库存");
if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType()))
throw conflict("已装箱库存请按箱唛出库");
if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode()))
throw conflict("整托出库必须存在托盘绑定");
}
if ("PALLET".equals(request.unitType())) {
String pallet = locked.get(0).getPalletCode();
if (locked.stream().anyMatch(c -> !Objects.equals(pallet, c.getPalletCode()))) throw conflict("整托出库只能选择同一托盘");
var all = returnCartons.listActivePallet(warehouseId, pallet);
if (all.size() != marks.size() || all.stream().anyMatch(c -> !customerId.equals(c.getCustomerId())
|| !marks.contains(c.getCartonMark()) || !"AVAILABLE".equals(c.getStatus())))
throw conflict("整托出库必须选择该托盘全部可用箱,不可遗漏或包含其他客户库存");
}
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId()));
outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount(0);
outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim());
outbound.setRequestHash(requestHash);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
for (ReturnInventoryCarton carton : locked) {
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); item.setCreatedAt(now); outboundPallets.insert(item);
item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo());
item.setSkuCode(trim(request.skuCode()));
item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null);
item.setCreatedAt(now); outboundPallets.insert(item);
carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId()));
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim());
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim());
delivery.setPackageCount(marks.size()); delivery.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0);
delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "RETURN-DELIVERY"));
delivery.setVersion(0L); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now);
deliveryOrders.insert(delivery);
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
return taskResult(outbound, task);
}
private DeliveryResponses.PdaTask replayReturnOutbound(String customerId, OutboundOrder existing, String requestHash) {
if (!"RETURN_OUTBOUND".equals(existing.getOutboundType()) || !requestHash.equals(existing.getRequestHash()))
throw conflict("相同幂等键不能用于不同的退货出库请求");
return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId()));
}
@Transactional(readOnly = true)
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
String customerId, String warehouseId) {
@@ -744,11 +706,11 @@ public class DeliveryTransferService {
throw conflict("Idempotency key was reused with a different outbound pick request");
}
OutboundOrder replayOutbound = requireOutbound(task);
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())) {
OutboundPalletItem replayItem = outboundPallets.selectById(replay.getOutboundPalletItemId());
requireReturnScan(replayOutbound, replayItem, request);
}
return taskResult(replayOutbound, task);
OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId());
if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType()) && replayItem != null && replayItem.getWarehousePalletId() == null))
requireReturnScan(replayOutbound, outboundPallets.selectById(request.outboundPalletItemId()), request);
return taskResult(requireOutbound(task), task);
}
OutboundOrder existingOutbound = requireOutbound(task);
OutboundOrder outbound = outboundOrders.lockByNo(customerId, existingOutbound.getOutboundNo());
@@ -761,16 +723,12 @@ public class DeliveryTransferService {
if (item == null || !customerId.equals(item.getCustomerId())
|| !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found");
if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity");
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) {
requireReturnScan(outbound, item, request);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱唛已完成拣货");
ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks());
requireReturnSource(outbound, item, carton);
if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks());
long outboundPickId = IdWorker.getId();
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(),
outbound.getOutboundNo(), outboundPickId);
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
if (value(item.getPickedPackageCount()) > 0) throw conflict("退货箱不可重复出库");
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), outbound.getOutboundNo(), pick.getId());
item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item);
updateOutboundPickingStatus(customerId, outbound);
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
@@ -934,7 +892,7 @@ public class DeliveryTransferService {
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
throw notFound("Outbound order not found in the bound warehouse");
}
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND", "RETURN_OUTBOUND").contains(outbound.getOutboundType())) {
throw conflict("整托扫码仅适用于一件代发或换标出库");
}
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
@@ -947,6 +905,17 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
DeliveryResponses.PdaTask result = taskResult(outbound, task);
for (OutboundPalletItem item : palletItems.stream().sorted(Comparator.comparing(OutboundPalletItem::getId)).toList()) {
if (value(item.getPickedPackageCount()) >= value(item.getPackageCount())) continue;
result = pdaPick(boundWarehouseId, operatorId, code,
new DeliveryRequests.PdaPick(item.getId(), null, 1L,
childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())));
}
return result;
}
List<PalletPickPlan> plans = new ArrayList<>();
int sequence = 0;
for (OutboundPalletItem item : palletItems) {
@@ -1052,6 +1021,10 @@ public class DeliveryTransferService {
+ value(position.getPickedPackages()) + value(position.getReceivingPackages()))
.sum();
if (remaining != 0) continue;
if (inventoryPositions.activePhysicalPallet(warehouseId, palletId) > 0) continue;
List<String> carrierCodes = items.stream().filter(item -> palletId.equals(item.getWarehousePalletId()))
.map(OutboundPalletItem::getPalletNo).filter(StringUtils::hasText).distinct().toList();
if (carrierCodes.stream().anyMatch(code -> !returnCartons.listActivePallet(warehouseId, code).isEmpty())) continue;
Long lockId = null;
WarehouseTransferPallet transferPallet = warehouseTransferPallets.selectOne(new LambdaQueryWrapper<WarehouseTransferPallet>()
.eq(WarehouseTransferPallet::getCustomerId, customerId)
@@ -1070,21 +1043,20 @@ public class DeliveryTransferService {
}
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
if (observed == null) throw notFound("Outbound order not found");
// Dispatch picking/departure owns the same dispatch -> outbound lock order.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
public void cancelOutbound(String outboundNo, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, "PLATFORM", request);
}
private void cancelOutboundResolved(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
required(request == null ? null : request.reason(), "取消原因不能为空");
// Confirmation locks the relabel application before its outbound document.
// Use the same order here to avoid cancellation/confirmation deadlocks.
OutboundOrder found = outboundOrders.findGlobalByNo(trim(outboundNo));
RelabelApplication application = found != null && "RELABEL_OUTBOUND".equals(found.getOutboundType())
? relabelApplications.lockByOutbound(found.getCustomerId(), found.getOutboundNo()) : null;
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
if (outbound.getDispatchOrderId() != null) {
if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId())
|| !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0
|| value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库");
fleetBindings.requireUnbound(dispatch.getId());
}
if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) {
throw conflict("Only an unexecuted outbound order can be cancelled");
}
@@ -1098,23 +1070,35 @@ public class DeliveryTransferService {
if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus())))
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
LocalDateTime now = LocalDateTime.now();
for (DeliveryOrder delivery : existingDeliveries) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
if ("RECEIVABLE".equals(fee.getDirection()) && "DELIVERY_FEE".equals(fee.getFeeItemCode())
&& "APPROVED".equals(fee.getReviewStatus()) && "ACTIVE".equals(fee.getSourceStatus())) {
feeSources.voidSource(fee.getSourceFeeId(), operatorId, new OverseasFeeSourceDtos.VoidRequest(
fee.getSourceVersion(), "取消出库 " + outboundNo + ";详见派送单取消原因",
"CANCEL_OUTBOUND:" + outbound.getId() + ":" + fee.getSourceFeeId()));
voidRequests++;
if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
if (application == null || !customerId.equals(application.getCustomerId())
|| !outbound.getOutboundNo().equals(application.getOutboundOrderNo())
|| !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus()))
throw conflict("换标出库关联状态已变化,不能取消");
for (OutboundPalletItem item : outboundItems) {
if (item.getWarehousePalletId() != null) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !Objects.equals(application.getId(), carton.getRelabelApplicationId())
|| !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货换标箱预留状态异常,不能取消出库");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
}
}
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND",
"出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now);
String reason = request.reason().trim();
for (int offset = 0; offset < reason.length(); offset += 480)
recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON",
"(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now);
relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId,
new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim()));
}
if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) {
ReturnInventoryCarton carton = returnCartons.lockMark(mark);
if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus()))
throw conflict("退货箱预留状态异常,不能取消出库");
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
}
for (DeliveryOrder delivery : existingDeliveries) {
delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1);
delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
}
@@ -1139,31 +1123,19 @@ public class DeliveryTransferService {
}
return;
}
if (!existingDeliveries.isEmpty()) {
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
var order = tailOrders.lockById(customerId, selected.getId());
if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue;
var previous = histories.selectOne(new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId())
.in(LastlegOrderStatusHistory::getEventId, transferEvents)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1"));
if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源");
String from = order.getCurrentStatus();
if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LastlegOrder>()
.eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo)
.set(LastlegOrder::getCurrentStatus, previous.getFromStatus())
.set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED")
.set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null)
.set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1)
throw conflict("订单关联已变化,请刷新后重试");
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history);
}
}
}
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
cancelOutboundResolved(outboundNo, operatorId, request);
}
public List<DeliveryResponses.PdaTask> pdaTasks(String warehouseId, String status) {
String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required");
return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream()
@@ -1180,7 +1152,8 @@ public class DeliveryTransferService {
task.getOutboundOrderId()).stream().map(item -> new DeliveryResponses.PdaTaskItem(item.getId(),
item.getPalletNo(), item.getSkuCode(), item.getInboundCode(), value(item.getPackageCount()),
value(item.getPickedPackageCount()), item.getStatus(),
"RETURN_OUTBOUND".equals(outbound.getOutboundType()) ? returnPickPositions(outbound, task, item)
("RETURN_OUTBOUND".equals(outbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
@@ -1219,28 +1192,21 @@ public class DeliveryTransferService {
if (item == null || !outbound.getId().equals(item.getOutboundOrderId())
|| !outbound.getCustomerId().equals(item.getCustomerId())) throw notFound("退货出库明细不存在");
if (request.inventoryPositionId() != null || !Long.valueOf(1).equals(request.packages()) || value(item.getPackageCount()) != 1)
throw conflict("退货出库须逐箱扫描,无需库存仓位");
if (request.cartonMarks() == null || request.cartonMarks().size() != 1 || request.cartonMarks().get(0) == null
|| !Objects.equals(item.getCartonMarks(), request.cartonMarks().get(0).trim()))
throw conflict("退货出库须逐箱或逐SKU扫描,无需库存仓位");
if (StringUtils.hasText(item.getSkuCode())) {
if (!Objects.equals(item.getSkuCode(), trim(request.skuCode()))) throw conflict("请扫描指定的退货SKU");
} else if (request.cartonMarks() == null || request.cartonMarks().size() != 1
|| !Objects.equals(item.getCartonMarks(), trim(request.cartonMarks().get(0))))
throw conflict("请扫描当前退货出库明细指定的箱唛");
}
private void requireReturnSource(OutboundOrder outbound, OutboundPalletItem item, ReturnInventoryCarton carton) {
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPalletItem item) {
ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks());
if (carton == null || !outbound.getCustomerId().equals(carton.getCustomerId())
|| !outbound.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !Objects.equals(item.getInboundCode(), carton.getSourceReturnNo())
|| !Objects.equals(item.getCartonMarks(), carton.getCartonMark()))
throw conflict("退货箱唛与本次出库的客户、仓库或来源退货单不一致");
}
private List<DeliveryResponses.PdaPickPosition> returnPickPositions(OutboundOrder outbound, OutboundPdaTask task,
OutboundPalletItem item) {
ReturnInventoryCarton carton = returnCartons.findMark(item.getCartonMarks());
requireReturnSource(outbound, item, carton);
boolean pending = Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus()) && value(item.getPickedPackageCount()) == 0;
long available = pending && "AVAILABLE".equals(carton.getStatus()) ? 1 : 0;
|| !returnStock.matchesReturnSource(carton, item.getInboundCode())) throw conflict("退货箱出库来源异常");
return List.of(new DeliveryResponses.PdaPickPosition(null, null, item.getCartonMarks(), item.getSkuCode(),
item.getInboundCode(), available, value(item.getPickedPackageCount())));
item.getInboundCode(), "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
}
private List<DeliveryResponses.PdaPickPosition> pdaPickPositions(String customerId, String warehouseId,
@@ -1286,7 +1252,7 @@ public class DeliveryTransferService {
&& Objects.equals(item.getInboundCode(), balance.getInboundCode());
}
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), outbound.getOutboundType(), task.getPushedAt(), task.getCompletedAt()); }
private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt(), outbound.getOutboundType()); }
private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) {
return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(),
outbound.getDispatchNo());
@@ -1353,75 +1319,51 @@ public class DeliveryTransferService {
public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("Delivery order not found");
OutboundOrder sourceOutbound = outboundOrders.selectById(delivery.getOutboundOrderId());
if (sourceOutbound != null && customerId.equals(sourceOutbound.getCustomerId())
&& ("RETURN_OUTBOUND".equals(sourceOutbound.getOutboundType())
|| ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType())
&& Objects.toString(delivery.getDeliveryGroupKey(), "").startsWith("RETURN_RELABEL:")))) {
List<DeliveryResponses.DeliverySku> cargo = new ArrayList<>();
for (OutboundPalletItem item : outboundPallets.findByOutbound(customerId, sourceOutbound.getId())) {
if ("RELABEL_OUTBOUND".equals(sourceOutbound.getOutboundType())
&& !Objects.equals(delivery.getDestinationWarehouseCode(), item.getDestinationWarehouseCode())) continue;
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, item.getCartonMarks());
if (carton == null) throw conflict("退货派送单来源箱记录缺失");
for (var line : returnCartonItems.listCarton(carton.getId()))
cargo.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
carton.getDestinationWarehouseCode(), delivery.getDeliveryAddress(), null, null,
line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity()));
}
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), cargo,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null);
}
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream()
.filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
Set<Long> sourceOrderIds = new LinkedHashSet<>();
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add);
picked.stream().map(com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku::orderId)
.filter(Objects::nonNull).forEach(sourceOrderIds::add);
List<Long> orderIds = List.copyOf(sourceOrderIds);
List<Long> orderIds = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage()))
.map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList();
Map<Long, LastlegOrder> ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream()
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List<LastlegOrderDetail> details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper<LastlegOrderDetail>().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
List<DeliveryResponses.DeliverySku> skus = deliveryCargoSkus(items, picked, ordersById, details);
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(),
feeCoverageEvidence(customerId, delivery.getOutboundNo(), items));
}
private List<DeliveryResponses.DeliverySku> deliveryCargoSkus(List<DispatchItem> items,
List<com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper.DeliveryPickedSku> picked,
Map<Long, LastlegOrder> orders, List<LastlegOrderDetail> details) {
List<DeliveryResponses.DeliverySku> result = new ArrayList<>();
List<DeliveryResponses.DeliverySku> skus = new ArrayList<>(details.stream()
.map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList());
for (DispatchItem item : items) {
var rows = picked.stream().filter(row -> Objects.equals(item.getId(), row.dispatchItemId())).toList();
long total = 0;
for (var row : rows) {
if (row.packages() == null || row.packages() <= 0) throw conflict("当前派送拣货数量无效");
total = Math.addExact(total, row.packages());
String skuCode = trim(row.skuCode());
if ("MIXED".equalsIgnoreCase(skuCode)) skuCode = null;
String currentSku = skuCode;
var matching = currentSku == null ? List.<LastlegOrderDetail>of() : details.stream()
.filter(detail -> Objects.equals(row.orderId(), detail.getOrderId())
&& currentSku.equals(detail.getSkuCode())).toList();
LastlegOrderDetail metadata = matching.size() == 1 ? matching.get(0) : null;
LastlegOrder order = row.orderId() == null ? null : orders.get(row.orderId());
result.add(new DeliveryResponses.DeliverySku(order == null ? item.getInboundCode() : order.getInboundCode(),
order == null ? null : order.getCustomerName(), firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()),
item.getDeliveryAddress(), metadata == null ? null : metadata.getShipmentNo(),
metadata == null ? null : metadata.getFbaTrackingNo(), skuCode,
skuCode == null ? null : row.productName(), Math.toIntExact(row.packages()), null, null));
}
long snapshotPackages = value(item.getPackageCount());
if (total > snapshotPackages) throw conflict("当前派送拣货数量超过冻结排车数量");
if (total < snapshotPackages) {
// Before picking, only the selected dispatch quantity is known; no SKU allocation is invented.
LastlegOrder order = item.getOrderId() == null ? null : orders.get(item.getOrderId());
result.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), order == null ? null : order.getCustomerName(),
firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()), item.getDeliveryAddress(),
null, null, null, null, Math.toIntExact(snapshotPackages - total), null, null));
if (!"RETURN_RELABEL".equals(item.getSourceStage())) continue;
for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) {
ReturnInventoryCarton carton = returnCartons.findCustomerMark(customerId, mark);
if (carton == null || !Objects.equals(delivery.getOverseasWarehouseId(), carton.getOverseasWarehouseId()))
throw conflict("退货换标派送单来源箱记录缺失");
for (var line : returnCartonItems.listCarton(carton.getId()))
skus.add(new DeliveryResponses.DeliverySku(item.getInboundCode(), delivery.getCustomerName(),
item.getFbaWarehouseCode(), delivery.getDeliveryAddress(), null, null,
line.getSkuCode(), line.getProductNameZh(), 1, null, null, line.getQuantity()));
}
}
return List.copyOf(result);
}
private com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence feeCoverageEvidence(String customerId, String outboundNo, List<DispatchItem> items) {
var outbound = outboundNo == null ? null : outboundOrders.findByNo(customerId, outboundNo);
if (outbound == null || outbound.getFeeCoverageJson() == null) return null;
try {
var evidence = objectMapper.readValue(outbound.getFeeCoverageJson(), com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence.class);
var ids = items.stream().map(DispatchItem::getId).collect(Collectors.toSet());
var cargo = evidence.cargo().stream().filter(row -> ids.contains(row.dispatchItemId())).toList();
var inboundIds = cargo.stream().filter(row -> "FIRSTLEG".equals(row.sourceRegion()))
.map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::sourceInboundId).collect(Collectors.toSet());
var references = evidence.references().stream().filter(row -> inboundIds.contains(row.source().sourceInboundId().toString())).toList();
return new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence(evidence.cargoHash(), cargo, references, evidence.operatorId(), evidence.confirmedAt());
} catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); }
return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus,
feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId()).stream().map(this::feeRow).toList(),
deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList(), null);
}
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
@@ -1483,6 +1425,7 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -1539,11 +1482,6 @@ public class DeliveryTransferService {
java.util.function.Function<T, BigDecimal> getter) {
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static <T> BigDecimal sumKnownPhysicalWeight(List<T> items,
java.util.function.Function<T, BigDecimal> getter) {
if (items.isEmpty() || items.stream().map(getter).anyMatch(weight -> weight == null || weight.signum() <= 0)) return null;
return items.stream().map(getter).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
long selectedPackages, int scale) {
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
@@ -77,10 +77,7 @@ public class CargoDiscardController {
private String scope(AuthenticatedUser user, String requested) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requested == null || requested.isBlank() || "0".equals(requested)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requested.trim();
return requested == null || requested.isBlank() || "0".equals(requested) ? null : requested.trim();
}
return String.valueOf(user.tenantId());
}
@@ -14,7 +14,7 @@ public final class CargoDiscardResponses {
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String inboundCodes,
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
@@ -51,7 +51,7 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
@@ -171,36 +171,7 @@ public class CargoDiscardService {
application.setCreatedAt(now); application.setUpdatedAt(now);
applications.insert(application);
// A discard request is also a customer-visible tail-leg order. Keep the
// application as the workflow record and link the order through its
// immutable source id so cancellation/status changes stay traceable.
LastlegOrder tailOrder = new LastlegOrder();
tailOrder.setId(IdWorker.getId());
tailOrder.setOrderNo(application.getDiscardNo());
tailOrder.setCustomerId(customerId);
tailOrder.setOrderType("CARGO_DISCARD");
tailOrder.setInboundCode(application.getInboundCodes());
tailOrder.setInboundOrderNo(application.getDiscardNo());
tailOrder.setSourceRegion("LASTLEG_DISCARD");
tailOrder.setSourceInboundId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceOrderId(String.valueOf(application.getId()));
tailOrder.setSourceOrderNo(application.getDiscardNo());
tailOrder.setOverseasWarehouseId(warehouseId);
tailOrder.setOverseasWarehouseName(warehouseName);
tailOrder.setPackageCount(total);
tailOrder.setCurrentStatus(PENDING_REVIEW);
tailOrder.setVersion(0);
tailOrder.setCreatedAt(now);
tailOrder.setUpdatedAt(now);
tailOrder.setSourceEventId("CARGO_DISCARD:" + application.getId());
tailOrder.setSourceLoadingPlanId("CARGO_DISCARD");
tailOrder.setSourcePayloadHash(requestHash);
orders.insert(tailOrder);
LastlegOrderStatusHistory initialOrderHistory = new LastlegOrderStatusHistory();
initialOrderHistory.setId(IdWorker.getId()); initialOrderHistory.setCustomerId(customerId); initialOrderHistory.setOrderId(tailOrder.getId());
initialOrderHistory.setFromStatus(null); initialOrderHistory.setToStatus(PENDING_REVIEW);
initialOrderHistory.setEventId("CARGO_DISCARD_CREATE:" + application.getId()); initialOrderHistory.setRemark(trim(request.remark())); initialOrderHistory.setCreatedAt(now);
orderHistories.insert(initialOrderHistory);
// 废弃申请独立承载废弃流程,不创建尾程订单。
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
for (CargoDiscardRequests.Item input : request.items()) {
@@ -218,7 +189,7 @@ public class CargoDiscardService {
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
.eq(CargoDiscardApplication::getCustomerId, customerId)
.eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId)
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
@@ -432,7 +403,7 @@ public class CargoDiscardService {
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt());
@@ -258,6 +258,27 @@ public interface DispatchCandidateMapper {
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
@Select("<script>SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," +
"MIN(ri.source_warehouse_name) AS overseasWarehouseName,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," +
"GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," +
"c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," +
"GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " +
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
"<if test='orderNo != null and orderNo != \"\"'>AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') </if>" +
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
"AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " +
"GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code</script>")
List<DispatchPalletCandidate> nativeReturnRelabelAvailable(@Param("customerId") String customerId,
@Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo);
@org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " +
"WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'")
int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@@ -44,6 +44,7 @@ public class DispatchPalletCandidate {
private String contactPhone;
private Long returnReceiptId;
private String returnReceiptNo;
private boolean returnCartonSource;
@com.fasterxml.jackson.annotation.JsonProperty("sourceType")
public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; }
@@ -8,8 +8,12 @@ import jakarta.validation.constraints.Size;
public final class DispatchPdaRequests {
private DispatchPdaRequests() {}
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark) {}
public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
@NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) {
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
}
}
public record Depart(@NotBlank String idempotencyKey, String remark) {}
public record CompletePicking(@NotBlank String idempotencyKey) {}
}
@@ -16,7 +16,15 @@ public final class DispatchPdaResponses {
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages,
long pickedPackages, List<Position> positions) {}
long pickedPackages, List<Position> positions, List<ReturnCarton> returnCartons) {
public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
int scheduledPackages, long pickedPackages, List<Position> positions) {
this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode,
warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of());
}
}
public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {}
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
long pickedPackages) {}
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
@@ -15,6 +15,7 @@ public class DispatchPdaPick {
private Long taskId;
private Long dispatchItemId;
private Long inventoryPositionId;
private Long returnCartonId;
private Long packages;
private Long productQuantity;
private String idempotencyKey;
@@ -16,6 +16,7 @@ public class DispatchPdaPickOrder {
private Long pickId;
private Long receiptId;
private Long returnReceiptId;
private Long returnCartonId;
private Long orderId;
private Long packages;
private Long productQuantity;
@@ -54,6 +54,7 @@ public class DispatchPdaWorkflowService {
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
@Resource private DispatchPickOrderAttributionService pickAttribution;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock;
@Transactional(readOnly = true)
public List<DispatchPdaResponses.Task> list(String warehouseId, String status) {
@@ -115,13 +116,23 @@ public class DispatchPdaWorkflowService {
throw notFound("Dispatch pallet item not found");
}
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
InventoryPosition position = positions.selectById(request.positionId());
if (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage());
if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位");
if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位");
InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId());
if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId())
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) {
throw conflict("Inventory position does not belong to this dispatch pallet");
}
long picked = picks.totalItem(customerId, task.getId(), itemId);
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
long pickId = IdWorker.getId();
Long returnCartonId = null;
if (nativeReturn) {
pickAttribution.prepareNativeReturn(customerId, item.getOrderId());
returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(),
pickId, order.getDispatchNo()).getId();
} else {
pickAttribution.preparePicking(customerId, request.positionId(),
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null);
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
@@ -142,12 +153,14 @@ public class DispatchPdaWorkflowService {
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(),
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
keyBase + ":SHIP", request.remark()));
}
DispatchPdaPick pick = new DispatchPdaPick();
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId);
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
pickAttribution.attribute(customerId, pick,
if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId());
else pickAttribution.attribute(customerId, pick,
"RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId());
long total = picked + request.packages();
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
@@ -289,6 +302,13 @@ public class DispatchPdaWorkflowService {
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
List<DispatchPdaResponses.Item> values = new ArrayList<>();
for (DispatchItem item : source) {
if ("RETURN_RELABEL".equals(item.getSourceStage())) {
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(),
picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item)));
continue;
}
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
@@ -162,6 +162,24 @@ public class DispatchPickOrderAttributionService {
}
public record OrderPackages(Long orderId, long packages) {}
/** Use the same order-before-stock lock order as ordinary dispatch picks. */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void prepareNativeReturn(String customerId, Long orderId) {
LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId);
if (order == null || Boolean.TRUE.equals(order.getDetained()))
throw conflict("退货换标履约订单缺失或暂扣");
}
/** Native return boxes have their own stock ledger, but fulfill this relabel tail order. */
public void attributeNativeReturn(String customerId, DispatchPdaPick pick, Long orderId) {
LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId);
if (order == null || Boolean.TRUE.equals(order.getDetained())) throw conflict("退货换标履约订单缺失或暂扣");
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); row.setOrderId(orderId);
row.setReturnCartonId(pick.getReturnCartonId());
row.setPackages(pick.getPackages()); row.setProductQuantity(pick.getProductQuantity()); row.setCreatedAt(pick.getPickedAt());
if (attributions.insert(row) != 1 || orders.increasePicked(customerId, orderId, pick.getPackages()) != 1)
throw conflict("退货换标排车拣货归属或箱数不一致");
}
private static long value(Long value) { return value == null ? 0 : value; }
private static long value(Integer value) { return value == null ? 0 : value; }
private static ResponseStatusException conflict(String message) {

Some files were not shown because too many files have changed in this diff Show More