diff --git a/AGENTS.md b/AGENTS.md
index 28b316cf6..35f4819ff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@
### 后端任务最小读取集
- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
-- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。
+- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。
- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
@@ -78,6 +78,7 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
+| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
## 公共模块快速索引
@@ -105,7 +106,7 @@
## 契约、数据库与验证入口
-- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
+- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
@@ -140,7 +141,7 @@
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
-- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取
+- 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务
- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
diff --git a/Jenkinsfile b/Jenkinsfile
index 36bbea19e..01e5992d5 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -161,7 +161,7 @@ pipeline {
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
- string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址')
+ string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
@@ -190,9 +190,12 @@ pipeline {
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
- if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
+ if (!requestedRepository) {
error('必须填写真实镜像仓库地址')
}
+ if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
+ error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
+ }
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
diff --git a/Jenkinsfile.service b/Jenkinsfile.service
index 310979127..a83e45409 100644
--- a/Jenkinsfile.service
+++ b/Jenkinsfile.service
@@ -49,7 +49,7 @@ pipeline {
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
- string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
+ string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
@@ -101,6 +101,9 @@ pipeline {
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
+ if (imageRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
+ error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
+ }
if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java
index 7859c7a44..f63732284 100644
--- a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java
+++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java
@@ -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<>(
diff --git a/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java b/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java
new file mode 100644
index 000000000..8bf838451
--- /dev/null
+++ b/common-security/src/test/java/com/baiyue/common/security/JwksJwtDecoderAutoConfigurationTest.java
@@ -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();
+ }
+}
diff --git a/config/nacos/test/file-service.yml b/config/nacos/test/file-service.yml
index 2d1ff9358..48155dcdb 100644
--- a/config/nacos/test/file-service.yml
+++ b/config/nacos/test/file-service.yml
@@ -123,4 +123,76 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
+ LAST_MILE_ORDER_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches
+ required-permission: platform:overseas-warehouse:last-mile-order:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_INVENTORY_BALANCE:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches
+ required-permission: platform:overseas-warehouse:inventory:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_INVENTORY_LEDGER:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
+ required-permission: platform:overseas-warehouse:inventory:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_QUOTE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches
+ required-permission: platform:overseas-master-data:quote:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ MONTHLY_EXCHANGE_RATE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches
+ required-permission: platform:overseas-master-data:exchange-rate:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ VEHICLE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/VEHICLE_LIST/batches
+ required-permission: platform:overseas-master-data:vehicle:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ FLEET_QUOTE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches
+ required-permission: platform:fleet:quote:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ TRIP_OPERATIONS_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches
+ required-permission: platform:fleet:operations:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
diff --git a/deploy/sql/README.md b/deploy/sql/README.md
index ebf36ab58..dec949876 100644
--- a/deploy/sql/README.md
+++ b/deploy/sql/README.md
@@ -13,7 +13,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
-| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 157 |
+| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index beb625c27..e9d82747c 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -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));
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index d85e588b5..1516e2303 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -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操作人',
@@ -377,7 +379,6 @@ CREATE TABLE ll_dispatch_item (
source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
-
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
KEY idx_ll_dispatch_item_order (customer_id,order_id),
@@ -436,7 +437,6 @@ CREATE TABLE ll_dispatch_pallet_lock (
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
-
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key),
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
@@ -463,12 +463,13 @@ CREATE TABLE ll_dispatch_pda_pick (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
task_id bigint NOT NULL COMMENT 'PDA任务ID',
dispatch_item_id bigint NOT NULL COMMENT '派车明细ID',
- inventory_position_id bigint NOT NULL COMMENT '库存仓位记录ID',
+ inventory_position_id BIGINT NULL COMMENT '普通库存仓位记录ID',
packages bigint NOT NULL COMMENT '本次操作箱数',
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
picked_at datetime(3) NOT NULL COMMENT '拣货时间',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ return_carton_id BIGINT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key),
KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id)
@@ -508,10 +509,9 @@ CREATE TABLE ll_dispatch_pda_pick_order (
product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源',
+ return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥',
KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id),
- CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)),
-
-
+ 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),
PRIMARY KEY (id),
KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id),
KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id)
@@ -2373,10 +2373,13 @@ CREATE TABLE ll_return_inventory_carton (
merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
- UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
- CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (sku_types > 0) and (total_quantity > 0)) or ((status in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (sku_types = 0) and (total_quantity = 0)))),
- CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED')))
+ 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)),
+ CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED')),
+ source_unit_code VARCHAR(128) NULL COMMENT '来源单元业务码',
+ location_code VARCHAR(128) NULL COMMENT '退货箱当前库位',
+ putaway_at DATETIME(3) NULL COMMENT '退货箱上架时间',
+ source_unit_type VARCHAR(32) NULL COMMENT 'PALLET或BOX实物箱 PARCEL散SKU'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
CREATE TABLE ll_return_inventory_carton_item (
@@ -2389,7 +2392,8 @@ CREATE TABLE ll_return_inventory_carton_item (
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_carton_item (carton_id,sku_code),
KEY idx_ll_return_inventory_carton_item_customer (customer_id,sku_code),
- CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0))
+ CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK ((quantity > 0)),
+ packing_reserved_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '合箱计划预占件数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
CREATE TABLE ll_return_inventory_operation (
@@ -2403,7 +2407,8 @@ CREATE TABLE ll_return_inventory_operation (
updated_at datetime(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_return_inventory_operation_operator_key (operator_id,idempotency_key),
- KEY idx_ll_return_inventory_operation_result (result_carton_id)
+ KEY idx_ll_return_inventory_operation_result (result_carton_id),
+ result_json JSON NULL COMMENT '幂等操作不可变返回快照'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计';
CREATE TABLE ll_return_order (
@@ -2479,7 +2484,14 @@ CREATE TABLE ll_return_receipt (
UNIQUE KEY uk_ll_return_receipt_operator_key (operator_id,idempotency_key),
KEY idx_ll_return_receipt_order (return_order_id,signed_at),
CONSTRAINT chk_ll_return_receipt_bound CHECK ((bound_by in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))),
- CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0))
+ CONSTRAINT chk_ll_return_receipt_quantity CHECK ((received_quantity > 0)),
+ unit_type VARCHAR(16) NULL COMMENT '签收类型 PALLET/BOX/PARCEL',
+ unit_code VARCHAR(128) NULL COMMENT '签收单元业务码',
+ total_box_count BIGINT NULL COMMENT '签收SKU箱数汇总',
+ length DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
+ width DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
+ height DECIMAL(12,3) NULL COMMENT '实测尺寸cm',
+ weight DECIMAL(12,3) NULL COMMENT '实测重量kg'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实';
CREATE TABLE ll_return_receipt_batch (
@@ -2508,7 +2520,8 @@ CREATE TABLE ll_return_receipt_item (
PRIMARY KEY (id),
KEY idx_ll_return_receipt_item_receipt (receipt_id),
KEY idx_ll_return_receipt_item_order_item (return_order_item_id),
- CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0))
+ CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)),
+ box_count BIGINT NULL COMMENT 'SKU签收箱数'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细';
@@ -3350,6 +3363,36 @@ CREATE TABLE ll_warehouse_transfer_source_order (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源历史订单绑定';
-- 服务 schema:baiyue_lastlag_warehouse
+CREATE TABLE 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),
+ source_items_json JSON NULL COMMENT '精确来源箱和SKU锁定数量',
+ result_marks_json JSON NULL COMMENT '完成生成或并入的目标箱唛',
+ request_hash VARCHAR(64) NULL COMMENT '创建载荷摘要',
+ completion_hash VARCHAR(64) NULL COMMENT '完成载荷摘要',
+ result_labels_json JSON NULL COMMENT '计划完工标签快照'
+) ENGINE=InnoDB COMMENT='退货合箱计划';
+
+CREATE TABLE 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原始退货单不可变追溯';
+
CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastlag_warehouse;
@@ -3540,4 +3583,4 @@ CREATE TABLE event_delivery_attempt (
KEY idx_delivery_attempt_outcome (outcome,started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计';
-SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 157 AS initialized_tables;
+SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables;
diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md
deleted file mode 100644
index 85e616a0d..000000000
--- a/event-bridge-service/AGENTS.md
+++ /dev/null
@@ -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`、成功、失败、租约、最大次数和人工补投。
diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md
deleted file mode 100644
index 1c05c13ce..000000000
--- a/event-bridge-service/docs/development-observability-slo.md
+++ /dev/null
@@ -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;禁止为了通知便利把高基数标识加入指标标签。
diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md
deleted file mode 100644
index 00c2e42c3..000000000
--- a/event-bridge-service/docs/event-trace-propagation.md
+++ /dev/null
@@ -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、信封字段、哈希规则或可靠性状态机。
diff --git a/event-bridge-service/pom.xml b/event-bridge-service/pom.xml
deleted file mode 100644
index c1cd6fedc..000000000
--- a/event-bridge-service/pom.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
- 4.0.0
-
- com.baiyue.firstlegbaiyue-firstleg-platform
- 1.0.0-SNAPSHOT../pom.xml
-
- event-bridge-service
- event-bridge-service
-
- com.baiyue.sharedbaiyue-common-web${project.version}
- com.baiyue.sharedbaiyue-common-openapi${project.version}
- com.baiyue.sharedcommon-event-contract${project.version}
- com.baomidoumybatis-plus-spring-boot3-starter
- com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery
- com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config
- org.springframework.bootspring-boot-starter-actuator
- com.mysqlmysql-connector-jruntime
- org.springframework.bootspring-boot-starter-testtest
-
- org.springframework.bootspring-boot-maven-plugin
-
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java
deleted file mode 100644
index 04f9c3db7..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java
deleted file mode 100644
index fffbbea19..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java
deleted file mode 100644
index 7a2b939f0..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java
+++ /dev/null
@@ -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 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 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 status() {
- return ApiResponse.success(new BridgeStatus("运行中", region, true, false));
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java
deleted file mode 100644
index eaf57c555..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java
+++ /dev/null
@@ -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;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java
deleted file mode 100644
index 96f15d531..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java
+++ /dev/null
@@ -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;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java
deleted file mode 100644
index ff9d7efbc..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java
+++ /dev/null
@@ -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) {
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java
deleted file mode 100644
index 5d84a09df..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java
+++ /dev/null
@@ -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) {
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java
deleted file mode 100644
index 25c4bb324..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java
+++ /dev/null
@@ -1,4 +0,0 @@
-package com.baiyue.eventbridge.dto;
-
-public record EventReplayRequest(String reason) {
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java
deleted file mode 100644
index 755341428..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java
+++ /dev/null
@@ -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 {
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java
deleted file mode 100644
index 287a5faa0..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java
+++ /dev/null
@@ -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 {
- @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 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 observabilitySnapshots();
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java
deleted file mode 100644
index 667aaadb2..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java
+++ /dev/null
@@ -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 snapshots;
- try {
- snapshots = inboxMapper.observabilitySnapshots();
- } catch (RuntimeException exception) {
- log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception);
- return;
- }
- Map 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 aggregate(List snapshots) {
- Map 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> rows(Map aggregates, MetricValue value) {
- List> 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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java
deleted file mode 100644
index 1b97d2bdf..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java
+++ /dev/null
@@ -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 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;
- }
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java
deleted file mode 100644
index 24e89b303..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java
deleted file mode 100644
index ba4717409..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java
+++ /dev/null
@@ -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, "内部服务令牌无效");
- }
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java
deleted file mode 100644
index 1a0538a19..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java
deleted file mode 100644
index 632672e95..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java
deleted file mode 100644
index a8d4e7107..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.baiyue.eventbridge.service;
-
-import com.baiyue.eventbridge.domain.BridgeInbox;
-
-public interface FinanceEventClient {
- String deliver(BridgeInbox event) throws Exception;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java
deleted file mode 100644
index a0bb0e33e..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.baiyue.eventbridge.service;
-
-import com.baiyue.eventbridge.domain.BridgeInbox;
-
-public interface FirstlegEventClient {
- String deliver(BridgeInbox event) throws Exception;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java
deleted file mode 100644
index e86b022f9..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java
+++ /dev/null
@@ -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 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());
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java
deleted file mode 100644
index f888cd04f..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java
+++ /dev/null
@@ -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 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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java
deleted file mode 100644
index 2e01d3981..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java
+++ /dev/null
@@ -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 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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java
deleted file mode 100644
index 9e55d56bd..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java
+++ /dev/null
@@ -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 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);
- }
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java
deleted file mode 100644
index b542caebb..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java
+++ /dev/null
@@ -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);
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java
deleted file mode 100644
index 7fa21d3b3..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.baiyue.eventbridge.service;
-
-import com.baiyue.eventbridge.domain.BridgeInbox;
-
-public interface LastlegEventClient {
- String deliver(BridgeInbox event) throws Exception;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java
deleted file mode 100644
index fedcdf41c..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java
+++ /dev/null
@@ -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()
- .eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
- }
-
- @Override
- public void insert(BridgeInbox inbox) {
- mapper.insert(inbox);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java
deleted file mode 100644
index 17ff106b7..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java
+++ /dev/null
@@ -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 SECURITY_EVENT_TYPES = Set.of(
- EventTypes.IAM_USER_STATUS_CHANGED,
- EventTypes.IAM_TENANT_STATUS_CHANGED,
- EventTypes.IAM_PERMISSION_VERSION_CHANGED);
-
- private static final Set 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);
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java
deleted file mode 100644
index 87fd9e3eb..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.baiyue.eventbridge.service;
-
-import com.baiyue.eventbridge.domain.BridgeInbox;
-
-public interface SecurityProjectionEventClient {
- String deliver(BridgeInbox event) throws Exception;
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java
deleted file mode 100644
index dc2c78bd3..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java
+++ /dev/null
@@ -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());
- }
-}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java
deleted file mode 100644
index 82139ab51..000000000
--- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java
+++ /dev/null
@@ -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 previous;
-
- private EventBridgeTraceScope(Map previous) {
- this.previous = previous;
- }
-
- public static EventBridgeTraceScope open(BridgeInbox event) {
- Map 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);
- }
- }
-}
diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml
deleted file mode 100644
index f16fd66fc..000000000
--- a/event-bridge-service/src/main/resources/application.yml
+++ /dev/null
@@ -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
diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql
deleted file mode 100644
index 300f674ec..000000000
--- a/event-bridge-service/src/main/resources/db/event-bridge.sql
+++ /dev/null
@@ -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='跨区域事件投递尝试记录';
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java
deleted file mode 100644
index a18746bd5..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java
deleted file mode 100644
index 636db964f..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java
deleted file mode 100644
index 5c106df15..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java
+++ /dev/null
@@ -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\"");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
deleted file mode 100644
index 1b8dfb9d2..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
+++ /dev/null
@@ -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("令牌无效");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java
deleted file mode 100644
index 0869ea824..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java
+++ /dev/null
@@ -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 String sql(Method method, Class 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+", " ");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
deleted file mode 100644
index 3639dd0e5..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java
deleted file mode 100644
index 49c00c1c8..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java
deleted file mode 100644
index cac61af80..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFinanceEventClientTest.java
+++ /dev/null
@@ -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}");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java
deleted file mode 100644
index 69ed8424a..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java
+++ /dev/null
@@ -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");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java
deleted file mode 100644
index b2de28029..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpLastlegEventClientTest.java
+++ /dev/null
@@ -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");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java
deleted file mode 100644
index 9a31b6db0..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java
+++ /dev/null
@@ -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");
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java
deleted file mode 100644
index 4262a7bc5..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java
+++ /dev/null
@@ -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 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 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();
- }
-}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java
deleted file mode 100644
index 1dbf17567..000000000
--- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
index 4fcb7d5df..452319bb9 100644
--- a/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
+++ b/file/src/main/java/com/baiyue/file/client/ExportProviderClient.java
@@ -21,13 +21,14 @@ public class ExportProviderClient {
@Resource private WarehouseExportProviderClient warehouse;
@Resource private FirstlegExportProviderClient firstleg;
@Resource private FinanceExportProviderClient finance;
+ @Resource private LastlegExportProviderClient lastleg;
@Resource
private MeterRegistry meters;
@PostConstruct
void initializeClients() {
this.clients = Map.of("iam-service", iam, "order-service", order,
"warehouse-service", warehouse, "firstleg-service", firstleg,
- "finance-service", finance);
+ "finance-service", finance, "lastleg-service", lastleg);
}
public ApiResponse batch(String internalToken, String exportType,
diff --git a/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java b/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java
new file mode 100644
index 000000000..02e5bc552
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/client/LastlegExportProviderClient.java
@@ -0,0 +1,7 @@
+package com.baiyue.file.client;
+
+import org.springframework.cloud.openfeign.FeignClient;
+
+@FeignClient(name="lastleg-service", contextId="lastlegExportProviderClient",
+ url="${baiyue.clients.lastleg.url:}", configuration=ExportProviderFeignConfiguration.class)
+interface LastlegExportProviderClient extends ExportProviderFeignClient { }
diff --git a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
index bec3b2a44..712f64d2e 100644
--- a/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
+++ b/file/src/main/java/com/baiyue/file/config/ExportDefinitionRegistry.java
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
- private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
+ private static final Set SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service", "lastleg-service");
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
index a25401d95..2bf1f64fb 100644
--- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
+++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java
@@ -46,7 +46,11 @@ public class ExportTaskService {
}
validateParameters(type, request.parameters());
validateTypeParameters(type, request.parameters());
- String parametersJson = parametersJson(request.parameters());
+ Map parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
+ if ("TRIP_OPERATIONS_LIST".equals(type)) {
+ parameters.put("costVisible", user.permissions().contains("platform:fleet:trip:list"));
+ }
+ String parametersJson = parametersJson(parameters);
long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
FileExportTask task = new FileExportTask();
@@ -92,6 +96,7 @@ public class ExportTaskService {
public DownloadResource download(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
+ requireExportCostAccess(user, task);
if (!"SUCCEEDED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
}
@@ -103,6 +108,7 @@ public class ExportTaskService {
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
+ requireExportCostAccess(user, task);
var definition = registry.require(task.getExportType());
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
String permission = definition.getRequiredPermission();
@@ -128,6 +134,18 @@ public class ExportTaskService {
return response(repository.find(id));
}
+ private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
+ if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
+ try {
+ if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
+ && !user.permissions().contains("platform:fleet:trip:list")) {
+ throw new BizException(ResultCode.FORBIDDEN, "缺少出车成本读取权限,请重新创建不含成本的导出任务");
+ }
+ } catch (JsonProcessingException exception) {
+ throw new BizException(ResultCode.CONFLICT, "导出筛选快照无效,请重新创建任务");
+ }
+ }
+
private FileExportTask ownedTask(AuthenticatedUser user, long id) {
requirePlatform(user);
FileExportTask task = repository.find(id);
diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml
index 4c5595c8c..1dc24570c 100644
--- a/file/src/main/resources/application.yml
+++ b/file/src/main/resources/application.yml
@@ -35,6 +35,9 @@ spring:
firstlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
+ lastlegExportProviderClient:
+ connect-timeout: 3000
+ read-timeout: 15000
financeExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
@@ -203,4 +206,76 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
+ LAST_MILE_ORDER_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches
+ required-permission: platform:overseas-warehouse:last-mile-order:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_INVENTORY_BALANCE:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches
+ required-permission: platform:overseas-warehouse:inventory:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_INVENTORY_LEDGER:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
+ required-permission: platform:overseas-warehouse:inventory:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ OVERSEAS_QUOTE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches
+ required-permission: platform:overseas-master-data:quote:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ MONTHLY_EXCHANGE_RATE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches
+ required-permission: platform:overseas-master-data:exchange-rate:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ VEHICLE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/VEHICLE_LIST/batches
+ required-permission: platform:overseas-master-data:vehicle:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ FLEET_QUOTE_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches
+ required-permission: platform:fleet:quote:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
+ TRIP_OPERATIONS_LIST:
+ provider-service: lastleg-service
+ provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches
+ required-permission: platform:fleet:operations:export
+ app-scope: PLATFORM
+ enabled: true
+ max-rows: 200000
+ max-attempts: 3
+ batch-size: 200
management.endpoints.web.exposure.include: health,info,prometheus
diff --git a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
index fd86f9e01..4221bf5fb 100644
--- a/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
+++ b/file/src/test/java/com/baiyue/file/client/ExportProviderClientTest.java
@@ -25,6 +25,7 @@ class ExportProviderClientTest {
@MockBean private WarehouseExportProviderClient warehouse;
@MockBean private FirstlegExportProviderClient firstleg;
@MockBean private FinanceExportProviderClient finance;
+ @MockBean private LastlegExportProviderClient lastleg;
@Resource private ExportProviderClient client;
@Configuration(proxyBeanMethods = false)
@@ -48,6 +49,16 @@ class ExportProviderClientTest {
verifyNoInteractions(iam,warehouse,firstleg,finance);
}
+ @Test void routesLastlegExportsThroughTheBoundedInternalClient() {
+ var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
+ when(lastleg.batch(anyString(),anyString(),any())).thenReturn(response);
+ var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("lastleg-service");
+ var request=new ExportBatchRequest("9","0","PLATFORM","7",1,Map.of(),null,null,200);
+ assertSame(response,client.batch("token","LAST_MILE_ORDER_LIST",definition,request));
+ verify(lastleg).batch("token","LAST_MILE_ORDER_LIST",request);
+ verifyNoInteractions(iam,order,warehouse,firstleg,finance);
+ }
+
@Test void routesFinanceExportsToFinanceService() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
diff --git a/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java
new file mode 100644
index 000000000..15c4872e9
--- /dev/null
+++ b/file/src/test/java/com/baiyue/file/config/LastlegExportDefinitionContractTest.java
@@ -0,0 +1,48 @@
+package com.baiyue.file.config;
+
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+
+class LastlegExportDefinitionContractTest {
+ @Test void localAndNacosDefinitionsKeepTheSameBoundedProviderAndPermissions() throws Exception {
+ Map, ?> local = definitions(Path.of("src/main/resources/application.yml"));
+ Map, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
+ Map expected = Map.ofEntries(
+ Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
+ Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
+ Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
+ Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
+ Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "platform:overseas-master-data:exchange-rate:export"),
+ Map.entry("VEHICLE_LIST", "platform:overseas-master-data:vehicle:export"),
+ Map.entry("FLEET_QUOTE_LIST", "platform:fleet:quote:export"),
+ Map.entry("TRIP_OPERATIONS_LIST", "platform:fleet:operations:export"));
+ for (var entry : expected.entrySet()) {
+ var definition = (Map, ?>) local.get(entry.getKey());
+ assertNotNull(definition, entry.getKey());
+ assertEquals(definition, nacos.get(entry.getKey()));
+ assertEquals("lastleg-service", definition.get("provider-service"));
+ assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
+ assertEquals(entry.getValue(), definition.get("required-permission"));
+ assertEquals("PLATFORM", definition.get("app-scope"));
+ assertEquals(200, definition.get("batch-size"));
+ var controlled = new ExportDefinitionRegistry.Definition();
+ controlled.setProviderService("lastleg-service");
+ controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
+ controlled.setRequiredPermission(entry.getValue());
+ controlled.setAppScope("PLATFORM");
+ var registry = new ExportDefinitionRegistry();
+ assertDoesNotThrow(() -> registry.setDefinitions(Map.of(entry.getKey(), controlled)));
+ }
+ }
+
+ private Map, ?> definitions(Path path) throws Exception {
+ Object value = new Yaml().load(Files.readString(path));
+ for (String key : new String[]{"baiyue", "file", "export", "definitions"}) value = ((Map, ?>) value).get(key);
+ return (Map, ?>) value;
+ }
+}
+
diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
index d715efdae..de2d60427 100644
--- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
+++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java
@@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class})
@@ -55,6 +56,32 @@ class ExportTaskServiceTest {
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
}
+ @Test
+ void operationExportDerivesCostVisibilityFromAuthenticatedPermissions() {
+ var definition = definition();
+ definition.setRequiredPermission("platform:fleet:operations:export");
+ when(registry.require("TRIP_OPERATIONS_LIST")).thenReturn(definition);
+ when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
+ new ExportStateService.CreationResult(invocation.getArgument(0), true));
+ service.create(user(1L, Set.of("platform:fleet:operations:export")),
+ new CreateExportTaskRequest("TRIP_OPERATIONS_LIST", "XLSX", "出车记录", Map.of("costVisible", true)));
+ var captured = org.mockito.ArgumentCaptor.forClass(FileExportTask.class);
+ verify(states).createConstrained(captured.capture());
+ assertTrue(captured.getValue().getParametersJson().contains("\"costVisible\":false"));
+ }
+
+ @Test
+ void costPermissionCannotBeBypassedByDownloadAllOrRetry() {
+ FileExportTask task = new FileExportTask();
+ task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
+ task.setExportType("TRIP_OPERATIONS_LIST"); task.setParametersJson("{\"costVisible\":true}");
+ when(repository.find(9L)).thenReturn(task);
+ var reader = user(1L, Set.of("platform:download:all", "platform:fleet:operations:export"));
+ assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.download(reader,9L)).getResultCode());
+ assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.retry(reader,9L)).getResultCode());
+ verifyNoInteractions(storage);
+ }
+
@Test
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index 38d0741ce..4cc9b3c76 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -9,6 +9,8 @@
## 模块入口
+表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。
+
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md
deleted file mode 100644
index 8965757f4..000000000
--- a/gateway-overseas/AGENTS.md
+++ /dev/null
@@ -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、过期/错误签名和身份头伪造测试。
diff --git a/gateway-overseas/pom.xml b/gateway-overseas/pom.xml
deleted file mode 100644
index 01f656726..000000000
--- a/gateway-overseas/pom.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
- 4.0.0
-
- com.baiyue.firstleg
- baiyue-firstleg-platform
- 1.0.0-SNAPSHOT
- ../pom.xml
-
- gateway-overseas
- gateway-overseas
-
-
-
- org.springdoc
- springdoc-openapi-starter-webflux-ui
-
-
- com.baiyue.shared
- baiyue-common-core
- ${project.version}
-
-
- org.springframework.cloud
- spring-cloud-starter-gateway
-
-
- org.springframework.boot
- spring-boot-starter-security
-
-
- org.springframework.boot
- spring-boot-starter-oauth2-resource-server
-
-
- com.alibaba.cloud
- spring-cloud-starter-alibaba-nacos-discovery
-
-
- org.springframework.cloud
- spring-cloud-starter-loadbalancer
-
-
- com.alibaba.cloud
- spring-cloud-starter-alibaba-nacos-config
-
-
- com.alibaba.cloud
- spring-cloud-starter-alibaba-sentinel
-
-
- org.springframework.boot
- spring-boot-starter-actuator
-
-
- org.springframework.boot
- spring-boot-starter-test
- test
-
-
- io.projectreactor
- reactor-test
- test
-
-
- org.springframework.bootspring-boot-maven-plugin
-
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java
deleted file mode 100644
index 8514ad509..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java
deleted file mode 100644
index 5dae7304a..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java
+++ /dev/null
@@ -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 {
- private final String expectedAppCode;
-
- AppScopeAuthorizationManager(String expectedAppCode) {
- this.expectedAppCode = expectedAppCode;
- }
-
- @Override
- public Mono check(Mono 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"));
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java
deleted file mode 100644
index 0305a7498..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java
+++ /dev/null
@@ -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 {
- 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));
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java
deleted file mode 100644
index a23d519e5..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java
+++ /dev/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 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 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();
- }
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java
deleted file mode 100644
index 3dab56ba2..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java
+++ /dev/null
@@ -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 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);
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java
deleted file mode 100644
index e488beaa9..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java
+++ /dev/null
@@ -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("/+$", "");
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java
deleted file mode 100644
index e2e40b47e..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java
+++ /dev/null
@@ -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("/+$", "");
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java
deleted file mode 100644
index 4135c50d0..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java
+++ /dev/null
@@ -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 {
- private static final List 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));
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java
deleted file mode 100644
index f25941cec..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java
+++ /dev/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 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;
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java
deleted file mode 100644
index 5adcb9b12..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java
+++ /dev/null
@@ -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 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 strings(JsonNode values) {
- if (values == null || !values.isArray()) return Set.of();
- LinkedHashSet 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) { }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java
deleted file mode 100644
index 7f9bd9a97..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java
+++ /dev/null
@@ -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 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 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) { }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java
deleted file mode 100644
index 4d2de7857..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java
+++ /dev/null
@@ -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 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 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 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 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; }
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java
deleted file mode 100644
index a7ae9b3cd..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.baiyue.gateway.filter;
-
-import reactor.core.publisher.Mono;
-import java.util.Set;
-
-public interface OverseasSecurityValidationClient {
- Mono validate(long userId, long tenantId, long permissionVersion);
- record Result(boolean valid, String failureReason, Set roles, Set authorities) {
- public Result {
- roles = roles == null ? Set.of() : Set.copyOf(roles);
- authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
- }
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java
deleted file mode 100644
index b40726567..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.baiyue.gateway.filter;
-
-import reactor.core.publisher.Mono;
-
-/** 只传递本地验签后提取的稳定 claims,不传递 raw JWT。 */
-public interface PdaSessionValidationClient {
- Mono validate(long userId, int tokenVersion, long overseasWarehouseId);
-
- record Result(boolean valid, String failureReason) { }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java
deleted file mode 100644
index 4e94ba57b..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java
+++ /dev/null
@@ -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 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 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 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; }
- }
-}
diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java
deleted file mode 100644
index f0c00dc91..000000000
--- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java
+++ /dev/null
@@ -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 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 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"; }
- }
-}
diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml
deleted file mode 100644
index 0cd871367..000000000
--- a/gateway-overseas/src/main/resources/application-overseas.yml
+++ /dev/null
@@ -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 }
diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml
deleted file mode 100644
index fca38239b..000000000
--- a/gateway-overseas/src/main/resources/application.yml
+++ /dev/null
@@ -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}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
deleted file mode 100644
index 21e2371a7..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
+++ /dev/null
@@ -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")));
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java
deleted file mode 100644
index 474c2ec6f..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java
+++ /dev/null
@@ -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 audiences) {
- return Jwt.withTokenValue("token")
- .header("alg", "RS256")
- .subject("1001")
- .issuedAt(Instant.now())
- .expiresAt(Instant.now().plusSeconds(300))
- .audience(audiences)
- .build();
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java
deleted file mode 100644
index 6b1cea992..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java
+++ /dev/null
@@ -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());
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java
deleted file mode 100644
index 70757edf3..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java
+++ /dev/null
@@ -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/");
- }
- }
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java
deleted file mode 100644
index a5964dbe4..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java
deleted file mode 100644
index 2b67d9ffd..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java
deleted file mode 100644
index 81b21ddb1..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java
+++ /dev/null
@@ -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 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();
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java
deleted file mode 100644
index 4c1847ecb..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java
deleted file mode 100644
index 73155dbd8..000000000
--- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java
+++ /dev/null
@@ -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 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 claims(int tokenVersion, Long warehouseId) {
- var claims = new java.util.LinkedHashMap();
- 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();
- }
-}
diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md
index d7ad899ca..fc63cac7f 100644
--- a/gateway/AGENTS.md
+++ b/gateway/AGENTS.md
@@ -17,4 +17,4 @@
- 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。
- 订单与客户承载登录后 Admin/Client 入口;入库测量与计费接入到仓/测量/计费公开路由但保持内部测量接口隔离;国内货柜履约仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。
-- 国内货柜履约先读 `../../docs/头程到尾程/README.md` 和 `../../docs/国内货柜履约/README.md`;网关变更只服务当前维护入口已确认的模块,不以路由提前暴露未完成能力。开工门禁属于归档资料,只有历史追溯时读取。
+- 已知路由任务直接核对目标 Controller、公开访问域、权限、Gateway 配置和 OpenAPI;只有货柜业务范围或流程门禁不明时,才从 `../../docs/国内货柜履约/README.md` 或 `../../docs/头程到尾程/README.md` 进入。归档开工门禁仅用于历史追溯,不以新增路由推定业务已完成。
diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml
index 94758a195..fa4d963da 100644
--- a/gateway/src/main/resources/application.yml
+++ b/gateway/src/main/resources/application.yml
@@ -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}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
index 7378bee76..054ea3003 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java
@@ -60,7 +60,7 @@ public class PlatformTenantController {
})
public ApiResponse> page(
@RequestParam(defaultValue = "1") @Min(1) long page,
- @RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(1000) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml
index 66e01f4bf..0eee3defd 100644
--- a/iam/src/main/resources/application.yml
+++ b/iam/src/main/resources/application.yml
@@ -5,6 +5,16 @@ 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
@@ -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
diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql
index 4efba3d74..e34bd6725 100644
--- a/iam/src/main/resources/db/lastleg-permissions.sql
+++ b/iam/src/main/resources/db/lastleg-permissions.sql
@@ -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)
diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml
index e893dee83..28e223b91 100644
--- a/iam/src/main/resources/openapi/iam-v1.yaml
+++ b/iam/src/main/resources/openapi/iam-v1.yaml
@@ -93,7 +93,7 @@ paths:
description: 分页查询商户企业客户,需要 `platform:customer:list` 权限;联系电话和邮箱仅返回脱敏摘要。
parameters:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
+ - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 1000, default: 20}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses:
diff --git a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
index b2ed7fb78..33b1c9c2a 100644
--- a/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/controller/PlatformTenantControllerContractTest.java
@@ -30,7 +30,7 @@ class PlatformTenantControllerContractTest {
method.getAnnotation(PreAuthorize.class).value());
assertNotNull(annotation(method, 0, Min.class));
assertNotNull(annotation(method, 1, Min.class));
- assertEquals(200, annotation(method, 1, Max.class).value());
+ assertEquals(1000, annotation(method, 1, Max.class).value());
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
annotation(method, 3, Pattern.class).regexp());
diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java
index acb7a22f0..c7cdc23e9 100644
--- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java
+++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java
@@ -43,6 +43,8 @@ class OverseasPhase4PermissionSqlContractTest {
"platform:overseas-warehouse:inbound-order:smart-dispatch");
private static final List REQUIRED = List.of(
"platform:overseas-warehouse:last-mile-order:list",
+ "platform:overseas-warehouse:discard:list",
+ "platform:overseas-warehouse:discard:approve",
"platform:overseas-warehouse:warehouse-transfer:approve",
"platform:overseas-warehouse:inbound-order:list",
"platform:overseas-warehouse:inbound-order:fba-appointment",
@@ -75,7 +77,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(71).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()",
@@ -156,6 +158,12 @@ class OverseasPhase4PermissionSqlContractTest {
// Vehicle care already declares this guard directly on its finance-submit facade.
serviceCatalog.addAll(codes(repositoryFile(
"lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java")));
+ // Cargo discard declares its approval guard in the service rather than OverseasPermissions.
+ serviceCatalog.addAll(codes(repositoryFile(
+ "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java")));
+ assertThat(repositoryFile(
+ "lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java"))
+ .contains("user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)");
assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog);
}
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index 7afcd57ab..957978036 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -9,18 +9,20 @@
## 快速索引
+业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。
+
拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
-先沿目标引用追踪;状态、权限、资源归属或接口边界仍不清楚时,用 `rg -n -F '关键词' docs/business-index.md` 定向读取。纯文案、局部重构及已知入口不扩读无关约束。
+沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。
-退货预报/签收/库存/合箱/出库:`returnreceipt/`;人工计划自动生成托盘、柜级预览与头程托盘快照:`inboundplan/`。细则按对应词查业务索引。
+退货链查 `returnreceipt/`;计划、自动托盘、柜级预览及来源托盘查 `inboundplan/`。
| 功能 | 首个入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` |
-| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
+| 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md
index a02edfb28..72fbaa3bf 100644
--- a/lastleg-service/docs/business-index.md
+++ b/lastleg-service/docs/business-index.md
@@ -21,7 +21,7 @@
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
-| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
+| 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
index 0f2043c54..5255b933e 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
@@ -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")
@@ -257,20 +261,6 @@ public class LastlegOrderController {
return ApiResponse.success(null);
}
- @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
- @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
- @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
- public ResponseEntity export(
- @AuthenticationPrincipal AuthenticatedUser user,
- @RequestParam(required = false) String customerId,
- @Valid LastlegOrderRequests.Query query) {
- byte[] body = orderService.exportCsv(platformScope(user, customerId), query);
- return ResponseEntity.ok()
- .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
- .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
- .body(body);
- }
-
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
@Operation(summary = "将单票尾程订单转入 FBA 目的仓",
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
index 4c1c7725b..49bb33ff4 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java
@@ -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 cartonMarks, String remark, String idempotencyKey) {
+ this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, null, null);
+ }
+ public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo,
+ List 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 cartonMarks) {
+ this(outboundPalletItemId, inventoryPositionId, packages, idempotencyKey, cartonMarks, null);
}
}
public record PdaPalletPick(@NotBlank @Size(max=255) String idempotencyKey) {}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java
index 2fb6c2c51..d192e88fc 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java
@@ -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,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
index 7e45cc8d8..c89c0e2eb 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java
@@ -114,6 +114,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;
@@ -485,46 +486,91 @@ public class DeliveryTransferService {
public List returnOutboundCandidates(String customerId, String warehouseId) {
if (!StringUtils.hasText(warehouseId)) return List.of();
return returnCartons.findAvailable(required(customerId, "Customer is required"), warehouseId.trim()).stream()
+ .filter(carton -> !"PARCEL".equals(carton.getSourceUnitType()))
.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();
}
@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 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 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.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(marks.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 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 warehouseTransferOutboundCandidates(
String customerId, String warehouseId) {
@@ -731,6 +777,11 @@ public class DeliveryTransferService {
@Transactional
public DeliveryResponses.PdaTask pdaPick(String warehouseId, String operatorId, String taskNo,
DeliveryRequests.PdaPick request) {
+ return pickResolved(warehouseId, operatorId, taskNo, request, false);
+ }
+
+ private DeliveryResponses.PdaTask pickResolved(String warehouseId, String operatorId, String taskNo,
+ DeliveryRequests.PdaPick request, boolean palletScan) {
OutboundPdaTask task = pdaTasks.lockByNoAndWarehouse(required(taskNo, "PDA task number is required"),
required(warehouseId, "PDA warehouse binding is required"));
if (task == null) throw notFound("PDA task not found in the bound warehouse");
@@ -744,9 +795,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);
+ if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType()) || "RELABEL_OUTBOUND".equals(replayOutbound.getOutboundType())) {
+ OutboundPalletItem replayItem = outboundPallets.selectById(request.outboundPalletItemId());
+ if ("RETURN_OUTBOUND".equals(replayOutbound.getOutboundType())
+ || (replayItem != null && replayItem.getWarehousePalletId() == null))
+ requireReturnScan(replayOutbound, replayItem, request);
}
return taskResult(replayOutbound, task);
}
@@ -761,16 +814,14 @@ 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)) {
+ if ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) && StringUtils.hasText(item.getPalletNo()) && !palletScan)
+ throw conflict("整托出库请扫描托盘码,不允许逐箱提交");
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 +985,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 +998,24 @@ public class DeliveryTransferService {
return taskResult(outbound, task);
}
+ if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) {
+ var expectedMarks = palletItems.stream().filter(item -> value(item.getPickedPackageCount()) < value(item.getPackageCount()))
+ .map(OutboundPalletItem::getCartonMarks).collect(Collectors.toSet());
+ var physical = returnCartons.listActivePallet(boundWarehouseId, palletCode);
+ if (physical.size() != expectedMarks.size() || physical.stream().anyMatch(carton ->
+ !task.getCustomerId().equals(carton.getCustomerId()) || !expectedMarks.contains(carton.getCartonMark())
+ || !"OUTBOUND_PENDING".equals(carton.getStatus())))
+ throw conflict("托盘内容或出库预留已变化,请刷新核对");
+ 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 = pickResolved(boundWarehouseId, operatorId, code,
+ new DeliveryRequests.PdaPick(item.getId(), null, 1L,
+ childKey(request.idempotencyKey(), "RETURN:" + item.getId()), List.of(item.getCartonMarks())), true);
+ }
+ return result;
+ }
+
List plans = new ArrayList<>();
int sequence = 0;
for (OutboundPalletItem item : palletItems) {
@@ -1052,6 +1121,10 @@ public class DeliveryTransferService {
+ value(position.getPickedPackages()) + value(position.getReceivingPackages()))
.sum();
if (remaining != 0) continue;
+ if (inventoryPositions.activePhysicalPallet(warehouseId, palletId) > 0) continue;
+ List 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()
.eq(WarehouseTransferPallet::getCustomerId, customerId)
@@ -1071,11 +1144,14 @@ public class DeliveryTransferService {
@Transactional
public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) {
+ required(request == null ? null : request.reason(), "取消原因不能为空");
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.
+ // Match dispatch picking and relabel confirmation lock order before locking outbound.
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
+ RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType())
+ ? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null;
OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo));
if (outbound == null) throw notFound("Outbound order not found");
if ("CANCELLED".equals(outbound.getStatus())) return;
@@ -1098,6 +1174,34 @@ 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();
+ 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);
+ }
+ }
+ 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) {
int voidRequests = 0;
for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) {
@@ -1139,7 +1243,7 @@ public class DeliveryTransferService {
}
return;
}
- if (!existingDeliveries.isEmpty()) {
+ if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) {
var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList();
for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) {
@@ -1180,7 +1284,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, task, item)
: pdaPickPositions(task.getCustomerId(), boundWarehouseId, item))).toList();
return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items);
}
@@ -1219,28 +1324,22 @@ 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 returnPickPositions(OutboundOrder outbound, OutboundPdaTask task, 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 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(), Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())
+ && value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount())));
}
private List pdaPickPositions(String customerId, String warehouseId,
@@ -1286,7 +1385,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,12 +1452,33 @@ 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 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 items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()).stream()
.filter(item -> delivery.getDeliveryGroupKey().equals(deliveryGroupKey(item))).toList();
+ var regularItems = items.stream().filter(item -> !"RETURN_RELABEL".equals(item.getSourceStage())).toList();
var picked = dispatchPickOrders.deliveryPickedSkus(customerId, delivery.getDispatchOrderId()).stream()
- .filter(row -> items.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
+ .filter(row -> regularItems.stream().anyMatch(item -> Objects.equals(item.getId(), row.dispatchItemId()))).toList();
Set sourceOrderIds = new LinkedHashSet<>();
- items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(sourceOrderIds::add);
+ regularItems.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 orderIds = List.copyOf(sourceOrderIds);
@@ -1366,7 +1486,19 @@ public class DeliveryTransferService {
.filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order));
List details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList(
new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds));
- List skus = deliveryCargoSkus(items, picked, ordersById, details);
+ List skus = new ArrayList<>(deliveryCargoSkus(regularItems, picked, ordersById, details));
+ for (DispatchItem item : items) {
+ 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 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(),
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java
index 0c6552627..60d987237 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java
@@ -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());
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java
index 1fa1c8e8f..14fb36737 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java
@@ -14,7 +14,7 @@ public final class CargoDiscardResponses {
List 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) {}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java
index c7d1268ef..56fccc1b7 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java
@@ -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 reservedReturnCartons = new LinkedHashSet<>();
for (CargoDiscardRequests.Item input : request.items()) {
@@ -218,7 +189,7 @@ public class CargoDiscardService {
public PageResult page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
- .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());
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java
index 52a94c557..a8d9e405d 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java
@@ -15,10 +15,22 @@ public final class DispatchRequests {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
- String vehiclePlate, String status, String fbaAppointmentNo) {
+ String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
+ public enum Stage {
+ BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED;
+ public List statuses() {
+ return switch (this) {
+ case BEFORE_LOADING -> List.of("PENDING_DEPARTURE");
+ case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART");
+ case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED");
+ case CANCELLED -> List.of("CANCELLED");
+ };
+ }
+ }
+
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
index b487c19b0..1eacc603e 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java
@@ -12,7 +12,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
- List- items, List bolLines) {
+ List
- items, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
index b676baa75..09cbd9984 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
@@ -258,6 +258,27 @@ public interface DispatchCandidateMapper {
@Param("orderNo") String orderNo,
@Param("relabelNo") String relabelNo);
+ @Select("")
+ List 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);
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java
index 9b57005ec..a6d90e94e 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java
@@ -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"; }
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java
index 0d5613033..f8591d251 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java
@@ -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) {}
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java
index 3a0c1aa9e..845cc9847 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java
@@ -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 positions) {}
+ long pickedPackages, List positions, List returnCartons) {
+ public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
+ String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel,
+ int scheduledPackages, long pickedPackages, List 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,
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java
index fe47e73c9..12daa7f71 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java
@@ -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;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java
index c59eb46d5..225cb9aa5 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java
@@ -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;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java
index 3eac86651..8e297a778 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java
@@ -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 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 source) {
List 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 palletPositions = item.getWarehousePalletId() == null ? List.of()
: positions.listPallet(customerId, item.getWarehousePalletId());
boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType());
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java
index 3c1bcc9e5..9d1f51a3d 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java
@@ -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) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
index 62a86b738..0ce8e808d 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java
@@ -75,8 +75,10 @@ public class DispatchService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private FleetDispatchBindingGuard fleetBindings;
+ @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
+ @Resource private ReturnRelabelDispatchStock returnRelabelStock;
public String resolveCustomerScopeForPlans(List planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -121,7 +123,9 @@ public class DispatchService {
List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
- List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
+ List relabel = java.util.stream.Stream.concat(
+ candidates.relabelAvailable(null, trimToNull(orderNo), null).stream(),
+ returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList();
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
@@ -566,15 +570,22 @@ public class DispatchService {
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
- RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo());
+ RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
relabelBindingGuard.requireBound(application);
- List selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
+ if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
+ List selected = java.util.stream.Stream.concat(
+ candidates.relabelAvailable(customerId, null, request.relabelNo()).stream(),
+ returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
+ if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource)
+ && !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘");
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
- if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
+ if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓");
LocalDateTime now = LocalDateTime.now();
@@ -583,6 +594,12 @@ public class DispatchService {
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
+ for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
+ if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId())
+ && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) {
+ item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item);
+ }
+ }
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
}
@@ -623,11 +640,16 @@ public class DispatchService {
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
+ .in(query.stage() != null, DispatchOrder::getStatus,
+ query.stage() == null ? List.of() : query.stage().statuses())
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
+ var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream()
+ .map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.of();
List records = page.getRecords().stream()
- .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList();
+ .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()),
+ List.of(), workflows.get(order.getDispatchNo()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -679,6 +701,8 @@ public class DispatchService {
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
+ if ("RETURN_RELABEL".equals(item.getSourceStage()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车");
List activeLocks = locks.selectList(new LambdaQueryWrapper()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
@@ -728,10 +752,17 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
}
String customerId = dispatch.getCustomerId();
+ if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱");
+ if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null)
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
+ for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
+ if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item);
+ }
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
@@ -764,6 +795,11 @@ public class DispatchService {
private DispatchResponse response(DispatchOrder order, List itemList,
List bolLineList) {
+ return response(order, itemList, bolLineList, null);
+ }
+
+ private DispatchResponse response(DispatchOrder order, List itemList,
+ List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -782,7 +818,7 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
- bolLineList == null ? List.of() : bolLineList);
+ bolLineList == null ? List.of() : bolLineList, workflow);
}
private static String candidateRoute(List selected) {
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java
new file mode 100644
index 000000000..887b5d037
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java
@@ -0,0 +1,85 @@
+package com.baiyue.lastleg.dispatch.service;
+
+import com.baiyue.lastleg.dispatch.entity.DispatchItem;
+import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
+import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
+import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
+import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
+import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
+import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
+import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
+import jakarta.annotation.Resource;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import java.time.LocalDateTime;
+import java.util.*;
+
+/** Native return stock stays in its own ledger; no synthetic InventoryPosition rows. */
+@Service
+public class ReturnRelabelDispatchStock {
+ @Resource private DispatchCandidateMapper candidates;
+ @Resource private ReturnInventoryCartonMapper cartons;
+ @Resource private PalletPhysicalGateway physical;
+ @Resource private ReturnInventoryService inventory;
+
+ public List candidates(String customer, String orderNo, String relabelNo) {
+ List result = new ArrayList<>();
+ for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) {
+ var pallet = physical.resolve(candidate.palletNo());
+ if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId())
+ || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue;
+ candidate.warehousePalletId(pallet.palletId());
+ result.add(candidate);
+ }
+ return result;
+ }
+
+ public void reserve(DispatchItem item) {
+ for (String mark : marks(item).stream().sorted().toList()) {
+ var carton = require(item, mark, "RELABEL_PENDING");
+ carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(LocalDateTime.now());
+ if (cartons.updateById(carton) != 1) throw conflict("退货箱排车预占失败");
+ }
+ }
+
+ public void release(DispatchItem item) {
+ for (String mark : marks(item).stream().sorted().toList()) {
+ var carton = require(item, mark, "OUTBOUND_PENDING");
+ carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(LocalDateTime.now());
+ if (cartons.updateById(carton) != 1) throw conflict("退货箱排车取消释放失败");
+ }
+ }
+
+ public ReturnInventoryCarton pick(DispatchItem item, String mark, long packages, long quantity, long pickId, String dispatchNo) {
+ if (packages != 1 || !marks(item).contains(mark)) throw conflict("请扫描本排车的来源箱唛,每次拣货一箱");
+ var carton = require(item, mark, "OUTBOUND_PENDING");
+ if (quantity != carton.getTotalQuantity()) throw conflict("退货箱 SKU 总数量已变化,请刷新后重试");
+ inventory.shipCarton(item.getCustomerId(), item.getOverseasWarehouseId(), carton.getSourceReturnNo(),
+ carton.getCartonMark(), dispatchNo, pickId);
+ return carton;
+ }
+
+ public List detail(DispatchItem item) {
+ return marks(item).stream().map(mark -> {
+ var carton = cartons.findCustomerMark(item.getCustomerId(), mark);
+ if (carton == null || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
+ throw conflict("排车来源退货箱缺失");
+ return new DispatchPdaResponses.ReturnCarton(carton.getId(), mark, carton.getStatus(), carton.getTotalQuantity());
+ }).toList();
+ }
+
+ private ReturnInventoryCarton require(DispatchItem item, String mark, String status) {
+ var carton = cartons.lockMark(mark);
+ if (carton == null || !item.getCustomerId().equals(carton.getCustomerId())
+ || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
+ || !Objects.equals(item.getPalletNo(), carton.getPalletCode())
+ || !Objects.equals(item.getSourceBusinessNo(), carton.getRelabelNo()) || !status.equals(carton.getStatus()))
+ throw conflict("退货箱已被占用或来源、客户、仓库、托盘不一致");
+ return carton;
+ }
+ private Set marks(DispatchItem item) {
+ return new LinkedHashSet<>(Arrays.asList(Objects.toString(item.getCartonMarks(), "").split(",")));
+ }
+ private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
index 6b1262676..9be19ed6f 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java
@@ -18,6 +18,8 @@ public final class LastlegOrderRequests {
private LastlegOrderRequests() {
}
+ public enum SailingStage { BEFORE_DEPARTURE, AFTER_DEPARTURE }
+
public record Query(
@Min(1) Long page,
@Min(1) @Max(200) Long size,
@@ -34,7 +36,8 @@ public final class LastlegOrderRequests {
Boolean abnormal,
Boolean detained,
LocalDateTime createdFrom,
- LocalDateTime createdTo
+ LocalDateTime createdTo,
+ SailingStage sailingStage
) {
public Query {
if (page == null || page == 0) page = 1L;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java
new file mode 100644
index 000000000..22ae6a31f
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java
@@ -0,0 +1,31 @@
+package com.baiyue.lastleg.export;
+
+import com.baiyue.common.core.export.ExportContracts.ExportColumn;
+import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
+import java.util.List;
+import java.util.Map;
+import java.util.Arrays;
+
+/** Explicit business columns; internal database identifiers are never exported. */
+final class LastlegExportColumns {
+ private LastlegExportColumns() { }
+ private static final Map DEFINITIONS = Map.ofEntries(
+ Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"),
+ Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"),
+ Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"),
+ Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"),
+ Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "month:月份,baseCurrency:基准币种,quoteCurrency:报价币种,usdToCnyAverage:月平均汇率:DECIMAL,status:状态,updatedAt:更新时间:DATETIME"),
+ Map.entry("VEHICLE_LIST", "plateNumber:车牌号,model:车型,fuelConsumptionPer100Km:油耗(L/100km):DECIMAL,mileageKm:当前公里码(km):DECIMAL,driverOrDispatchCompany:司机或外派单位,phone:联系电话,region:区域,ownership:车辆归属,status:状态,maxPalletSpaces:最大托位:DECIMAL,maxLoadKg:最大载重(kg):DECIMAL,maxVolumeCbm:可用容积(m³):DECIMAL,updatedAt:更新时间:DATETIME"),
+ Map.entry("FLEET_QUOTE_LIST", "quoteNo:报价号,details.kind:用途,partyName:往来单位,details.originCity:发货城市,details.destinationCode:目的仓码,details.distanceMiles:预估距离(mile):DECIMAL,details.currency:币种,details.selfPickupPrice:整车自提(每单):DECIMAL,details.fullDeliveryPrice:整车派送(每单):DECIMAL,details.looseDeliveryPrice:散板派送(每托):DECIMAL,details.validFrom:生效日:DATE,details.validTo:失效日:DATE,status:状态,recordVersion:版本:INTEGER,details.remark:备注"),
+ Map.entry("TRIP_OPERATIONS_LIST", "tripNo:车次号,status:状态,vehiclePlate:车牌,driverName:司机,warehouseName:起运仓,departedAt:实际发车UTC,completedAt:货物结案UTC,destinations:目的地,physicalPallets:实物托盘数:INTEGER,palletSpaces:标准托位:INTEGER,ownPackages:自有箱数:INTEGER,externalPackages:外接箱数:INTEGER,externalAgreedAmounts:外接约定金额(按币种),measured.actualDistanceMiles:实际里程(mile):DECIMAL,measured.actualFuelLitres:实际油耗(L):DECIMAL,measured.remark:实测备注,financeStatus:应收与实收,costs:直接成本"));
+ static List columns(String type) {
+ String definition = DEFINITIONS.get(type);
+ if (definition == null) throw new IllegalArgumentException("Unsupported Lastleg export type");
+ return Arrays.stream(definition.split(",")).map(value -> {
+ String[] parts = value.split(":");
+ return new ExportColumn(parts[0], parts[1],
+ parts.length == 3 ? ExportColumnType.valueOf(parts[2]) : ExportColumnType.STRING, 24);
+ }).toList();
+ }
+}
+
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java
new file mode 100644
index 000000000..5e680e9e6
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java
@@ -0,0 +1,27 @@
+package com.baiyue.lastleg.export;
+
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
+import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
+import com.baiyue.lastleg.security.InternalServiceTokenGuard;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/internal/exports/v1")
+public class LastlegExportProviderController {
+ private final InternalServiceTokenGuard guard;
+ private final LastlegExportProviderService service;
+
+ public LastlegExportProviderController(InternalServiceTokenGuard guard, LastlegExportProviderService service) {
+ this.guard = guard;
+ this.service = service;
+ }
+
+ @PostMapping("/{exportType}/batches")
+ public ApiResponse batch(
+ @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
+ @PathVariable String exportType, @RequestBody ExportBatchRequest request) {
+ guard.verify(token);
+ return ApiResponse.success(service.batch(exportType, request));
+ }
+}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java
new file mode 100644
index 000000000..53df619ac
--- /dev/null
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java
@@ -0,0 +1,146 @@
+package com.baiyue.lastleg.export;
+
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.export.ExportContracts.*;
+import com.baiyue.lastleg.dto.LastlegOrderRequests;
+import com.baiyue.lastleg.service.LastlegOrderService;
+import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
+import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
+import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests;
+import com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService;
+import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos;
+import com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService;
+import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
+import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
+import com.baiyue.lastleg.fleet.quote.FleetQuoteModels;
+import com.baiyue.lastleg.fleet.quote.FleetQuoteService;
+import com.baiyue.lastleg.fleet.reporting.TripOperationsModels;
+import com.baiyue.lastleg.fleet.reporting.TripOperationsService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import jakarta.validation.Validator;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/** Internal, platform-only batches. Reuses the same filters and business projections as the lists. */
+@Service
+public class LastlegExportProviderService {
+ @Resource private LastlegOrderService orders;
+ @Resource private InventoryQueryService inventory;
+ @Resource private OverseasQuoteService quotes;
+ @Resource private MonthlyExchangeRateService rates;
+ @Resource private VehicleApplicationService vehicles;
+ @Resource private FleetQuoteService fleetQuotes;
+ @Resource private TripOperationsService operations;
+ @Resource private ObjectMapper json;
+ @Resource private Validator validator;
+ @Value("${baiyue.security.internal-service-token}") private String signingKey;
+
+ public ExportBatchResponse batch(String type, ExportBatchRequest request) {
+ if (request == null || !"PLATFORM".equals(request.appCode()) || !"0".equals(request.tenantId())) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅支持平台导出");
+ }
+ if (request.taskId() == null || request.requestedBy() == null || request.dispatchVersion() < 1
+ || request.limit() < 1 || request.limit() > 200) throw invalid("导出批次参数无效");
+ List columns;
+ try { columns = LastlegExportColumns.columns(type); }
+ catch (IllegalArgumentException e) { throw invalid("不支持该导出类型"); }
+ int offset = cursor(request.cursor());
+ if (offset % request.limit() != 0) throw invalid("导出游标无效");
+ String snapshot = signature(type, request);
+ boolean first = request.snapshotToken() == null;
+ if ((first && offset != 0) || (!first && !MessageDigest.isEqual(snapshot.getBytes(StandardCharsets.UTF_8),
+ request.snapshotToken().getBytes(StandardCharsets.UTF_8)))) throw invalid("导出筛选快照不匹配");
+ Map parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
+ String customer = text(parameters.remove("customerId"));
+ if (customer != null && !customer.matches("[1-9][0-9]*")) throw invalid("客户参数无效");
+ String relabelNo = text(parameters.remove("relabelNo"));
+ boolean costVisible = Boolean.TRUE.equals(parameters.remove("costVisible"));
+ parameters.put("page", offset / request.limit() + 1);
+ parameters.put("size", request.limit());
+ PageResult> page = switch (type) {
+ case "LAST_MILE_ORDER_LIST" -> orders.page(customer, query(parameters, LastlegOrderRequests.Query.class), relabelNo);
+ case "OVERSEAS_INVENTORY_BALANCE" -> inventory.exportBalancePage(customer, query(parameters, InventoryQueryRequests.BalanceQuery.class));
+ case "OVERSEAS_INVENTORY_LEDGER" -> inventory.ledgers(customer, query(parameters, InventoryQueryRequests.LedgerQuery.class));
+ case "OVERSEAS_QUOTE_LIST" -> quotes.page(query(parameters, OverseasQuoteRequests.Query.class));
+ case "MONTHLY_EXCHANGE_RATE_LIST" -> rates.page(query(parameters, MonthlyExchangeRateDtos.Query.class));
+ case "VEHICLE_LIST" -> vehicles.page(query(parameters, VehicleModels.Query.class));
+ case "FLEET_QUOTE_LIST" -> fleetQuotes.page(query(parameters, FleetQuoteModels.Query.class));
+ case "TRIP_OPERATIONS_LIST" -> operations.report(query(parameters, TripOperationsModels.Query.class), costVisible).page();
+ default -> throw invalid("不支持该导出类型");
+ };
+ List