From 4fbf8bfb53c675177b693320f6dca1667d337e1b Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 25 Aug 2026 11:52:25 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitlab-ci.yml | 15 +-
AGENTS.md | 5 +-
README.md | 14 +-
common-event-contract/AGENTS.md | 3 +-
docs/同步调用依赖与失败传播治理基线.md | 105 +++++++
docs/头程项目目录结构与服务说明.md | 17 +-
firstleg/AGENTS.md | 1 +
...rstleg-internal-modularization-baseline.md | 189 ++++++++++++
.../scripts/analyze-firstleg-dependencies.mjs | 284 ++++++++++++++++++
.../analyze-firstleg-dependencies.test.mjs | 39 +++
.../firstleg-slice-dependencies.json | 48 +++
lastleg-service/AGENTS.md | 1 +
pom.xml | 23 ++
scripts/agents-index-contract.ps1 | 39 ++-
scripts/java-topology-contract.test.mjs | 146 +++++++++
.../synchronous-dependency-contract.test.mjs | 188 ++++++++++++
16 files changed, 1101 insertions(+), 16 deletions(-)
create mode 100644 docs/同步调用依赖与失败传播治理基线.md
create mode 100644 firstleg/docs/firstleg-internal-modularization-baseline.md
create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.mjs
create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.test.mjs
create mode 100644 firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
create mode 100644 scripts/java-topology-contract.test.mjs
create mode 100644 scripts/synchronous-dependency-contract.test.mjs
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 4dd6604f7..4c67d8bc7 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -13,6 +13,13 @@ cache:
paths:
- .m2/repository/
+Java拓扑合同:
+ stage: verify
+ image: node:22-alpine
+ cache: []
+ script:
+ - node --test scripts/java-topology-contract.test.mjs
+
校验与测试:
stage: verify
script:
@@ -25,10 +32,13 @@ cache:
- "**/target/surefire-reports/TEST-*.xml"
paths:
- "**/target/surefire-reports/"
+ - "**/target/site/jacoco/"
+ - "**/target/jacoco.exec"
打包服务:
stage: package
needs:
+ - Java拓扑合同
- 校验与测试
script:
- mvn $MAVEN_CLI_OPTS -DskipTests package
@@ -36,14 +46,17 @@ cache:
expire_in: 7 days
paths:
- gateway/target/*.jar
+ - gateway-overseas/target/*.jar
- auth/target/*.jar
- iam/target/*.jar
- order/target/*.jar
- firstleg/target/*.jar
+ - finance/target/*.jar
- warehouse/target/*.jar
+ - warehouse-overseas-service/target/*.jar
+ - lastleg-service/target/*.jar
- file/target/*.jar
- print/target/*.jar
- audit-service/target/*.jar
- integration-service/target/*.jar
- event-bridge-service/target/*.jar
-
diff --git a/AGENTS.md b/AGENTS.md
index 2d5d600f2..2b612f218 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,10 +37,11 @@
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
-| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` |
+| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
-| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` |
+| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
+| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
diff --git a/README.md b/README.md
index 884c9de44..d8202d879 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 柏悦头程平台
-本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
+本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
@@ -12,20 +12,24 @@
- `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
-- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
+- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。
+- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
-- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
+- `platform-bom`:统一管理仓库内公共依赖版本。
## 本地构建
```powershell
mvn -DskipTests validate
+node --test scripts/java-topology-contract.test.mjs
mvn test
```
+GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
+
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
-本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。
+国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
@@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界
-仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
+仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md
index c35c4997c..bf9e87321 100644
--- a/common-event-contract/AGENTS.md
+++ b/common-event-contract/AGENTS.md
@@ -17,9 +17,10 @@
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
+| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
-| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` |
+| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证
diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md
new file mode 100644
index 000000000..5755b0d21
--- /dev/null
+++ b/docs/同步调用依赖与失败传播治理基线.md
@@ -0,0 +1,105 @@
+# 同步调用依赖与失败传播治理基线
+
+## 目的与口径
+
+本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
+
+“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
+
+- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果;
+- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦;
+- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用;
+- `EXTERNAL`:Integration 对第三方供应商的调用。
+
+离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。
+
+## 当前服务边
+
+### OpenFeign
+
+| 调用方 | 被调用方 | 客户端数 | 主要上下文 |
+|---|---|---:|---|
+| Auth | IAM | 2 | 登录授权、注册,`REQUEST` |
+| IAM | Auth | 1 | 账号开通/管理,`REQUEST` |
+| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` |
+| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` |
+| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` |
+| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` |
+| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` |
+| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` |
+| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 |
+| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` |
+| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` |
+| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 |
+| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` |
+| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` |
+| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` |
+| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 |
+| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` |
+| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` |
+
+合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。
+
+### 原始 HTTP
+
+| 调用方 | 被调用方 | 实现 | 上下文与当前策略 |
+|---|---|---|---|
+| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 |
+| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 |
+| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 |
+| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 |
+| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 |
+| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 |
+
+## 依赖环基线
+
+按服务边计算,当前唯一非平凡强连通分量包含 8 个服务:
+
+`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg`
+
+其中优先关注的直接双向边为:
+
+1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖;
+2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递;
+3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询;
+4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照;
+5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加;
+6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。
+
+强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。
+
+## 退出顺序
+
+1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。
+2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。
+3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。
+4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。
+5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。
+6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。
+7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。
+8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。
+
+## 错误、重试与熔断基线
+
+- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。
+- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。
+- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。
+- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。
+- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。
+- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。
+
+## 直接受影响验证
+
+| 变更范围 | 最低验证 |
+|---|---|
+| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` |
+| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 |
+| Auth/IAM | `mvn -pl auth,iam -am test` |
+| Order/Firstleg | `mvn -pl order,firstleg -am test` |
+| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
+| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
+| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
+| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 |
+| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
+
+以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md
index 9c15e66f1..0fad3c9d3 100644
--- a/docs/头程项目目录结构与服务说明.md
+++ b/docs/头程项目目录结构与服务说明.md
@@ -10,7 +10,7 @@
## 1. 项目定位
-`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。
+`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。
当前技术基线:
@@ -24,7 +24,7 @@
- SpringDoc OpenAPI / Swagger
- GitLab、Jenkins、Docker Compose
-本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。
+国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。
## 2. 根目录结构
@@ -39,11 +39,15 @@ baiyue-firstleg/
├─ common-event-contract/ # 国内外跨区域事件统一契约
│
├─ gateway/ # 统一 API 网关
+├─ gateway-overseas/ # 海外区域 API 网关
├─ auth/ # 统一登录与账号凭据
├─ iam/ # 租户、成员、角色、权限、企业审核
├─ order/ # 头程订单中心
├─ firstleg/ # 履约任务、二期入库、测量与计费
├─ warehouse/ # 仓库主数据、托盘与库位资源
+├─ finance/ # 财务账期、账单、收付款与核销
+├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
+├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁
├─ file/ # 资质、导出与测量图片文件中心
├─ print/ # 打印中心
├─ audit-service/ # 审计日志中心
@@ -170,7 +174,7 @@ config/nacos/
└─ cn/ # 国内服务器配置模板
```
-每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
+`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
### `deploy/`
@@ -179,7 +183,8 @@ deploy/
├─ docker/Dockerfile # 通用 Java 17 镜像
├─ env/.env.test.example # 环境变量示例
├─ scripts/deploy-services.sh # Jenkins 增量部署脚本
-├─ docker-compose.yml # 11 个业务服务编排
+├─ docker-compose.yml # 国内区域服务编排
+├─ docker-compose.overseas.yml # 海外区域服务编排
├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施
├─ sql/ # 测试/存量环境统一迁移入口与说明
└─ service-map.yml # 模块、JAR、端口映射
@@ -276,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新
- 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。
- Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。
- 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。
-- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。
+- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。
- 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。
-- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。
+- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index 72b9200eb..a9afeb4a0 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -32,6 +32,7 @@
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
+| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md
new file mode 100644
index 000000000..3a37feacd
--- /dev/null
+++ b/firstleg/docs/firstleg-internal-modularization-baseline.md
@@ -0,0 +1,189 @@
+# Firstleg 内部模块化基线与首切片方案
+
+## 1. 范围与生成方式
+
+本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为:
+
+- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。
+- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。
+
+可重复命令:
+
+```powershell
+node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
+node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json
+node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot
+```
+
+分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。
+
+## 2. 当前 SCC
+
+### 2.1 主源码物理包 SCC
+
+当前只有一个多节点物理包 SCC:
+
+```text
+com.baiyue.firstleg.mapper
+ <-> com.baiyue.firstleg.repository
+```
+
+直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。
+
+合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包:
+
+```text
+controller -> loading.repository -> loading.service -> mapper
+-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service
+-> pricing -> repository -> service -> controller
+```
+
+### 2.2 逻辑切片 SCC
+
+逻辑切片图存在一个八节点 SCC:
+
+```text
+booking-customs-trucking
+finance-integration
+inbound
+measurement-pricing
+outbound
+shared-technical
+shipping-nodes
+split-loading
+```
+
+`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。
+
+主要双向依赖包括:
+
+| 双向关系 | 直接证据 |
+|---|---|
+| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox |
+| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox |
+| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 |
+| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO |
+| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper |
+| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 |
+
+## 3. 八个业务切片现状映射
+
+类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。
+
+| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 |
+|---|---|---:|---:|---|
+| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper |
+| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 |
+| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity |
+| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity |
+| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity |
+| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity |
+| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 |
+| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 |
+
+共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。
+
+## 4. 泄漏与逆向依赖基线
+
+主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下:
+
+| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 |
+|---|---:|---:|---|
+| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` |
+| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox |
+| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` |
+| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity |
+| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` |
+| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` |
+| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` |
+| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` |
+
+逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。
+
+## 5. 首切片选择:客户统计
+
+选择 `customer-statistics`,不是因为预设,而是因为以下实际证据:
+
+1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。
+2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。
+3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。
+4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。
+5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。
+
+限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。
+
+## 6. 首切片目标目录与允许依赖
+
+```text
+customerstatistics/
+ adapter/in/web/
+ PlatformCustomerTransitStatisticsController.java
+ application/
+ CustomerTransitStatisticsQueryService.java
+ model/CustomerTransitStatisticsModels.java
+ port/out/CustomerTransitStatisticsReader.java
+ adapter/out/persistence/
+ CustomerTransitStatisticsMapper.java
+```
+
+允许方向:
+
+```text
+adapter.in.web -> application
+adapter.out.persistence -> application.port.out
+application -> application.model + common-core
+```
+
+额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。
+
+## 7. 精确迁移清单、调用方与测试
+
+首切片实施时只迁移/新增以下文件:
+
+| 当前文件 | 目标/动作 |
+|---|---|
+| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 |
+| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 |
+| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port |
+| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 |
+| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 |
+| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper |
+| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 |
+| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 |
+
+外部调用方只读核对,不在首切片修改:
+
+- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml`
+- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts`
+- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts`
+
+## 8. 回滚边界
+
+- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。
+- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。
+- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。
+- 首切片完成前不开始第二切片。
+
+## 9. 当前只读合同与后续协作
+
+本轮已增加模块内、无第三方依赖的只读合同:
+
+- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。
+- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。
+- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。
+
+与其他架构专项的依赖:
+
+- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。
+- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。
+
+## 10. 最小验证命令
+
+```powershell
+node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs
+node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown
+mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test
+```
+
+当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。
+
diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs
new file mode 100644
index 000000000..e2de4acf8
--- /dev/null
+++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs
@@ -0,0 +1,284 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+
+const moduleRoot = path.resolve(import.meta.dirname, '..');
+const sourceRoots = {
+ main: path.join(moduleRoot, 'src', 'main', 'java'),
+ test: path.join(moduleRoot, 'src', 'test', 'java'),
+};
+const internalPrefix = 'com.baiyue.firstleg.';
+
+const sliceRules = [
+ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/],
+ ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
+ ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
+ ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
+ ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
+ ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
+ ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
+ ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/],
+];
+
+function parseArgs(argv) {
+ const options = { sourceSet: 'main', format: 'json', output: null, check: null };
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === '--source-set') options.sourceSet = argv[++index];
+ else if (argument === '--format') options.format = argv[++index];
+ else if (argument === '--output') options.output = argv[++index];
+ else if (argument === '--check') options.check = argv[++index];
+ else if (argument === '--help') {
+ process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n');
+ process.exit(0);
+ } else throw new Error(`Unknown argument: ${argument}`);
+ }
+ if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`);
+ if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`);
+ return options;
+}
+
+function walkJavaFiles(root) {
+ if (!fs.existsSync(root)) return [];
+ const result = [];
+ const pending = [root];
+ while (pending.length > 0) {
+ const current = pending.pop();
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+ const absolute = path.join(current, entry.name);
+ if (entry.isDirectory()) pending.push(absolute);
+ else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute);
+ }
+ }
+ return result.sort();
+}
+
+function stripCommentsAndStrings(source) {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
+ .replace(/\/\/[^\r\n]*/g, ' ')
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
+ .replace(/'(?:\\.|[^'\\])*'/g, "''");
+}
+
+function classifySlice(fqcn) {
+ for (const [slice, expression] of sliceRules) {
+ if (expression.test(fqcn)) return slice;
+ }
+ return 'shared-technical';
+}
+
+function classifyKind(fqcn) {
+ const packageName = fqcn.slice(0, fqcn.lastIndexOf('.'));
+ if (packageName.includes('.client')) return 'cross-service-adapter';
+ if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter';
+ if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model';
+ return 'business-application';
+}
+
+function parseSource(file, sourceSet) {
+ const raw = fs.readFileSync(file, 'utf8');
+ const source = stripCommentsAndStrings(raw);
+ const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1];
+ const typeName = path.basename(file, '.java');
+ if (!packageName) throw new Error(`Missing package declaration: ${file}`);
+ const fqcn = `${packageName}.${typeName}`;
+ const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]);
+ return {
+ file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'),
+ sourceSet,
+ packageName,
+ typeName,
+ fqcn,
+ imports,
+ source,
+ slice: classifySlice(fqcn),
+ kind: classifyKind(fqcn),
+ };
+}
+
+function collectSources(sourceSet) {
+ const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet];
+ return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set)));
+}
+
+function resolveDependencies(sources) {
+ const knownTypes = new Map(sources.map(source => [source.fqcn, source]));
+ const typesByPackage = new Map();
+ for (const source of sources) {
+ if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []);
+ typesByPackage.get(source.packageName).push(source);
+ }
+ const dependencies = [];
+ const seen = new Set();
+ for (const source of sources) {
+ const candidates = new Set();
+ for (const imported of source.imports) {
+ if (knownTypes.has(imported)) candidates.add(imported);
+ else if (imported.startsWith(internalPrefix)) {
+ const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`));
+ if (owner) candidates.add(owner);
+ }
+ }
+ for (const samePackageType of typesByPackage.get(source.packageName) ?? []) {
+ if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) {
+ candidates.add(samePackageType.fqcn);
+ }
+ }
+ for (const targetFqcn of candidates) {
+ if (targetFqcn === source.fqcn) continue;
+ const key = `${source.fqcn}->${targetFqcn}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ dependencies.push({ source: source.fqcn, target: targetFqcn });
+ }
+ }
+ return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
+}
+
+function aggregateEdges(dependencies, nodeOf) {
+ const counts = new Map();
+ for (const dependency of dependencies) {
+ const source = nodeOf(dependency.source);
+ const target = nodeOf(dependency.target);
+ if (source === target) continue;
+ const key = `${source}\0${target}`;
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+ return [...counts].map(([key, count]) => {
+ const [source, target] = key.split('\0');
+ return { source, target, count };
+ }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`));
+}
+
+function stronglyConnectedComponents(nodes, edges) {
+ const adjacency = new Map(nodes.map(node => [node, []]));
+ for (const edge of edges) adjacency.get(edge.source)?.push(edge.target);
+ const indexByNode = new Map();
+ const lowLink = new Map();
+ const stack = [];
+ const onStack = new Set();
+ const components = [];
+ let nextIndex = 0;
+ function visit(node) {
+ indexByNode.set(node, nextIndex);
+ lowLink.set(node, nextIndex);
+ nextIndex += 1;
+ stack.push(node);
+ onStack.add(node);
+ for (const target of adjacency.get(node) ?? []) {
+ if (!indexByNode.has(target)) {
+ visit(target);
+ lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target)));
+ } else if (onStack.has(target)) {
+ lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target)));
+ }
+ }
+ if (lowLink.get(node) === indexByNode.get(node)) {
+ const component = [];
+ let current;
+ do {
+ current = stack.pop();
+ onStack.delete(current);
+ component.push(current);
+ } while (current !== node);
+ components.push(component.sort());
+ }
+ }
+ for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node);
+ return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0]));
+}
+
+function buildReport(sourceSet) {
+ const sources = collectSources(sourceSet);
+ const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source]));
+ const dependencies = resolveDependencies(sources);
+ const packages = [...new Set(sources.map(source => source.packageName))].sort();
+ const slices = [...new Set(sources.map(source => source.slice))].sort();
+ const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName);
+ const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice);
+ const packageSccs = stronglyConnectedComponents(packages, packageEdges);
+ const sliceSccs = stronglyConnectedComponents(slices, sliceEdges);
+ const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice);
+ const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind));
+ const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName));
+ return {
+ schemaVersion: 1,
+ generatedFrom: sourceSet,
+ moduleRoot: 'firstleg',
+ summary: {
+ javaFiles: sources.length,
+ internalClassDependencies: dependencies.length,
+ packages: packages.length,
+ slices: slices.length,
+ crossSliceClassDependencies: directCrossSliceDependencies.length,
+ crossSliceModelLeaks: modelLeaks.length,
+ crossSliceMapperLeaks: mapperLeaks.length,
+ },
+ sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])),
+ classes: sources.map(({ source, imports, ...entry }) => entry),
+ packageEdges,
+ sliceEdges,
+ packageSccs,
+ sliceSccs,
+ directCrossSliceDependencies,
+ modelLeaks,
+ mapperLeaks,
+ };
+}
+
+function renderMarkdown(report) {
+ const lines = [
+ '# Firstleg internal dependency report', '',
+ `- Source set: ${report.generatedFrom}`,
+ `- Java files: ${report.summary.javaFiles}`,
+ `- Internal class dependencies: ${report.summary.internalClassDependencies}`,
+ `- Package SCCs (>1 node): ${report.packageSccs.length}`,
+ `- Slice SCCs (>1 node): ${report.sliceSccs.length}`,
+ '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|',
+ ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`),
+ '', '## Slice SCCs', '',
+ ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
+ '', '## Package SCCs', '',
+ ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']),
+ '', '## Cross-slice model and mapper leaks', '',
+ ...[...report.modelLeaks, ...report.mapperLeaks]
+ .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index)
+ .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``),
+ '',
+ ];
+ return lines.join('\n');
+}
+
+function renderDot(report) {
+ const lines = ['digraph firstleg_slices {', ' rankdir=LR;'];
+ for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`);
+ lines.push('}', '');
+ return lines.join('\n');
+}
+
+function checkContract(report, contractPath) {
+ const absolute = path.resolve(process.cwd(), contractPath);
+ const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
+ const allowed = new Set(contract.allowedSliceEdges ?? []);
+ const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
+ const additions = actual.filter(edge => !allowed.has(edge));
+ if (additions.length > 0) {
+ process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`);
+ process.exitCode = 1;
+ }
+}
+
+const options = parseArgs(process.argv.slice(2));
+const report = buildReport(options.sourceSet);
+if (options.check) checkContract(report, options.check);
+const rendered = options.format === 'json'
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : options.format === 'markdown'
+ ? renderMarkdown(report)
+ : renderDot(report);
+if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
+else process.stdout.write(rendered);
+
diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs
new file mode 100644
index 000000000..9cf71321f
--- /dev/null
+++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs
@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import path from 'node:path';
+import test from 'node:test';
+
+const moduleRoot = path.resolve(import.meta.dirname, '..');
+const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs');
+const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json');
+
+function analyze() {
+ return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], {
+ cwd: moduleRoot,
+ encoding: 'utf8',
+ }));
+}
+
+test('current source does not add slice dependencies beyond the baseline', () => {
+ execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], {
+ cwd: moduleRoot,
+ encoding: 'utf8',
+ });
+});
+
+test('customer statistics remains an independently packaged low-coupling slice', () => {
+ const report = analyze();
+ const classes = report.classes.filter(entry => entry.slice === 'customer-statistics');
+ assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
+ 'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController',
+ 'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos',
+ 'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper',
+ 'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService',
+ ]);
+ const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics');
+ const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics');
+ assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]);
+ assert.deepEqual(incoming, []);
+ assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false);
+});
+
diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
new file mode 100644
index 000000000..4607d9891
--- /dev/null
+++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json
@@ -0,0 +1,48 @@
+{
+ "schemaVersion": 1,
+ "description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
+ "allowedSliceEdges": [
+ "booking-customs-trucking->inbound",
+ "booking-customs-trucking->measurement-pricing",
+ "booking-customs-trucking->outbound",
+ "booking-customs-trucking->shared-technical",
+ "booking-customs-trucking->split-loading",
+ "customer-statistics->shared-technical",
+ "finance-integration->inbound",
+ "finance-integration->measurement-pricing",
+ "finance-integration->shared-technical",
+ "finance-integration->split-loading",
+ "inbound->measurement-pricing",
+ "inbound->outbound",
+ "inbound->shared-technical",
+ "inbound->split-loading",
+ "measurement-pricing->booking-customs-trucking",
+ "measurement-pricing->finance-integration",
+ "measurement-pricing->inbound",
+ "measurement-pricing->shared-technical",
+ "outbound->booking-customs-trucking",
+ "outbound->shared-technical",
+ "outbound->shipping-nodes",
+ "outbound->split-loading",
+ "shared-technical->booking-customs-trucking",
+ "shared-technical->finance-integration",
+ "shared-technical->inbound",
+ "shared-technical->measurement-pricing",
+ "shared-technical->split-loading",
+ "shipping-nodes->booking-customs-trucking",
+ "shipping-nodes->inbound",
+ "shipping-nodes->shared-technical",
+ "shipping-nodes->split-loading",
+ "split-loading->booking-customs-trucking",
+ "split-loading->inbound",
+ "split-loading->measurement-pricing",
+ "split-loading->outbound",
+ "split-loading->shared-technical"
+ ],
+ "sliceSpecificRules": {
+ "customer-statistics": {
+ "allowedOutgoing": ["shared-technical"],
+ "allowedIncoming": []
+ }
+ }
+}
diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md
index 7adcf9f70..f70c92a95 100644
--- a/lastleg-service/AGENTS.md
+++ b/lastleg-service/AGENTS.md
@@ -27,6 +27,7 @@
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
+| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 |
| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
diff --git a/pom.xml b/pom.xml
index 35b79ff33..cbb4a2780 100644
--- a/pom.xml
+++ b/pom.xml
@@ -65,6 +65,7 @@
2.5.0
3.13.0
3.4.1
+ 0.8.12
2.3.3
@@ -137,6 +138,28 @@
[3.8.8,)
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco-maven-plugin.version}
+
+
+ 准备覆盖率采集
+ prepare-agent
+
+
+ 生成覆盖率报告
+ verify
+ report
+
+
+ HTML
+ XML
+
+
+
+
+
diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1
index 4052af722..ad8e06621 100644
--- a/scripts/agents-index-contract.ps1
+++ b/scripts/agents-index-contract.ps1
@@ -115,6 +115,19 @@ foreach ($pdaApplication in $pdaApplications) {
}
}
+# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。
+$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js'
+$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath
+Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关'
+foreach ($disabledOperation in @(
+ '库存盘点', '商品移库', '托盘管理',
+ '换标收货', '换标执行', '换标复核',
+ '库存查询', '库位查询', '托盘查询'
+)) {
+ $disabledPattern = "(?s)name:\s*'$([regex]::Escape($disabledOperation))'.{0,300}?available:\s*false"
+ Assert-Matches $overseasWorkbench $disabledPattern "Overseas PDA 禁用入口 $disabledOperation"
+}
+
# 文档声明为正式开放的 Client 海外仓页面必须仍是 active 路由。
$clientOverseasRoutesPath = Join-Path $workspaceRoot 'apps/client-web/src/modules/overseas-warehouse/routes.ts'
$clientOverseasRoutes = Read-RequiredText $clientOverseasRoutesPath
@@ -134,6 +147,7 @@ $overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath
foreach ($gatewayPath in @(
'/api/merchant/v1/last-leg/**',
'/api/platform/v1/last-leg/**',
+ '/api/pda/v1/last-leg/**',
'/api/merchant/v1/warehouses/**',
'/api/platform/v1/warehouse-admin/**'
)) {
@@ -174,6 +188,9 @@ $criticalPaths = @(
'apps/pda-overseas-uniapp/src/pages/operation/operation.vue',
'apps/pda-overseas-uniapp/src/config/runtime.js',
'apps/pda-overseas-uniapp/src/api/auth.js',
+ 'apps/pda-overseas-uniapp/src/api/inbound.js',
+ 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js',
+ 'apps/pda-overseas-uniapp/src/api/overseas.js',
'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java',
@@ -187,6 +204,22 @@ $criticalPaths = @(
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java',
'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java',
'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java',
+ 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java',
+ 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java',
+ 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java',
@@ -197,6 +230,10 @@ $criticalPaths = @(
'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java',
'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java',
'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java',
+ 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java',
+ 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java',
+ 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java',
'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java',
@@ -256,5 +293,5 @@ if ($failures.Count -gt 0) {
exit 1
}
-Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green
+Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green
exit 0
diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs
new file mode 100644
index 000000000..180022fb0
--- /dev/null
+++ b/scripts/java-topology-contract.test.mjs
@@ -0,0 +1,146 @@
+import assert from 'node:assert/strict'
+import { access, readFile } from 'node:fs/promises'
+import test from 'node:test'
+
+const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8'))
+ .replaceAll('\r\n', '\n')
+
+const sorted = (values) => [...values].sort()
+
+const parseRootModules = (xml) => {
+ const body = xml.match(/([\s\S]*?)<\/modules>/)?.[1] ?? ''
+ return [...body.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim())
+}
+
+const parsePom = (xml) => {
+ const project = xml.replace(/[\s\S]*?<\/parent>/, '')
+ return {
+ artifactId: project.match(/([^<]+)<\/artifactId>/)?.[1]?.trim(),
+ springBoot: /spring-boot-maven-plugin<\/artifactId>/.test(project)
+ }
+}
+
+const parseServiceMap = (yaml) => {
+ const services = new Map()
+ let current
+ for (const line of yaml.split('\n')) {
+ const service = line.match(/^ ([a-z][a-z0-9-]+):$/)
+ if (service) {
+ current = {}
+ services.set(service[1], current)
+ continue
+ }
+ const property = line.match(/^ ([a-zA-Z]+): (.+)$/)
+ if (!current || !property) continue
+ const [, key, raw] = property
+ current[key] = key === 'port'
+ ? Number(raw)
+ : key === 'regions'
+ ? raw.slice(1, -1).split(',').map((value) => value.trim())
+ : raw
+ }
+ return services
+}
+
+const parseGroovyList = (source, variable) => {
+ const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? ''
+ return [...body.matchAll(/'([^']+)'/g)].map((match) => match[1])
+}
+
+const parseGroovyMap = (source, variable) => {
+ const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? ''
+ return new Map([...body.matchAll(/'([^']+)':\s*'([^']+)'/g)]
+ .map((match) => [match[1], match[2]]))
+}
+
+const parseCompose = (yaml) => {
+ const body = yaml.match(/^services:\n([\s\S]*?)(?=^networks:)/m)?.[1] ?? ''
+ const matches = [...body.matchAll(/^ ([a-z][a-z0-9-]+):\s*$/gm)]
+ return new Map(matches.map((match, index) => {
+ const block = body.slice(match.index, matches[index + 1]?.index ?? body.length)
+ return [match[1], Number(block.match(/^ SERVER_PORT: (\d+)$/m)?.[1])]
+ }))
+}
+
+test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => {
+ const modules = parseRootModules(await read('pom.xml'))
+ const services = parseServiceMap(await read('deploy/service-map.yml'))
+
+ assert.equal(modules.length, 22)
+ assert.equal(new Set(modules).size, 22)
+ assert.equal(services.size, 15)
+
+ const bootModules = []
+ for (const module of modules) {
+ const pom = parsePom(await read(`${module}/pom.xml`))
+ if (pom.springBoot) bootModules.push(module)
+ if (!services.has(module)) continue
+ const service = services.get(module)
+ assert.equal(service.artifactId, pom.artifactId, `${module} artifactId drifted`)
+ assert.equal(service.jar, `${module}/target/${pom.artifactId}-1.0.0-SNAPSHOT.jar`)
+ }
+ assert.deepEqual(sorted(bootModules), sorted(services.keys()))
+ assert.equal(new Set([...services.values()].map(({ port }) => port)).size, services.size)
+})
+
+test('Jenkins and GitLab package exactly the service-map modules', async () => {
+ const services = parseServiceMap(await read('deploy/service-map.yml'))
+ const expected = sorted(services.keys())
+ const coordinator = await read('Jenkinsfile')
+ const servicePipeline = await read('Jenkinsfile.service')
+ const serviceJars = parseGroovyMap(servicePipeline, 'serviceJars')
+ const gitlab = await read('.gitlab-ci.yml')
+ const gitlabArtifacts = [...gitlab.matchAll(/^ - ([a-z][a-z0-9-]+)\/target\/\*\.jar$/gm)]
+ .map((match) => match[1])
+
+ assert.deepEqual(sorted(parseGroovyList(coordinator, 'serviceModules')), expected)
+ assert.deepEqual(sorted(parseGroovyList(servicePipeline, 'serviceModules')), expected)
+ assert.deepEqual(sorted(serviceJars.keys()), expected)
+ assert.deepEqual(sorted(gitlabArtifacts), expected)
+ for (const [module, service] of services) assert.equal(serviceJars.get(module), service.jar)
+})
+
+test('GitLab runs the topology contract in a dedicated Node job', async () => {
+ const gitlab = await read('.gitlab-ci.yml')
+ const topologyJob = gitlab.match(/^Java拓扑合同:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? ''
+ const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? ''
+
+ assert.match(topologyJob, /^ stage: verify$/m)
+ assert.match(topologyJob, /^ image: node:[^\s]+$/m)
+ assert.match(topologyJob, /^ - node --test scripts\/java-topology-contract\.test\.mjs$/m)
+ assert.match(packageJob, /^ - Java拓扑合同$/m)
+})
+
+test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => {
+ for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) {
+ const pipeline = await read(path)
+ if (!pipeline.includes('java-topology-contract.test.mjs')) continue
+ assert.match(
+ pipeline,
+ /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/,
+ `${path} must declare how Node is provided before invoking the topology contract`
+ )
+ }
+})
+
+test('regional Compose and Nacos manifests cover the service-map topology', async () => {
+ const services = parseServiceMap(await read('deploy/service-map.yml'))
+ const composeByRegion = new Map([
+ ['CN', parseCompose(await read('deploy/docker-compose.yml'))],
+ ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))]
+ ])
+
+ for (const [module, service] of services) {
+ const regions = service.regions ?? ['CN']
+ for (const [region, compose] of composeByRegion) {
+ assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`)
+ if (!regions.includes(region)) continue
+ assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`)
+ await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url))
+ }
+ }
+ assert.deepEqual(
+ sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))),
+ sorted(services.keys())
+ )
+})
diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs
new file mode 100644
index 000000000..60c2af78b
--- /dev/null
+++ b/scripts/synchronous-dependency-contract.test.mjs
@@ -0,0 +1,188 @@
+import assert from 'node:assert/strict'
+import { readdir, readFile } from 'node:fs/promises'
+import path from 'node:path'
+import test from 'node:test'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+
+const serviceModules = [
+ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse',
+ 'integration-service', 'print', 'event-bridge-service', 'lastleg-service',
+ 'warehouse-overseas-service'
+]
+
+const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`]))
+
+const expectedFeignEdges = new Map(Object.entries({
+ 'auth-service->iam-service': 2,
+ 'file-service->finance-service': 1,
+ 'file-service->firstleg-service': 1,
+ 'file-service->iam-service': 2,
+ 'file-service->integration-service': 1,
+ 'file-service->order-service': 1,
+ 'file-service->warehouse-service': 1,
+ 'finance-service->file-service': 1,
+ 'finance-service->firstleg-service': 2,
+ 'finance-service->iam-service': 1,
+ 'finance-service->warehouse-service': 1,
+ 'firstleg-service->event-bridge-service': 1,
+ 'firstleg-service->file-service': 7,
+ 'firstleg-service->finance-service': 1,
+ 'firstleg-service->iam-service': 1,
+ 'firstleg-service->integration-service': 1,
+ 'firstleg-service->order-service': 2,
+ 'firstleg-service->print-service': 1,
+ 'firstleg-service->warehouse-service': 4,
+ 'iam-service->auth-service': 1,
+ 'iam-service->event-bridge-service': 1,
+ 'iam-service->firstleg-service': 1,
+ 'iam-service->warehouse-service': 1,
+ 'lastleg-service->event-bridge-service': 1,
+ 'lastleg-service->warehouse-service': 1,
+ 'order-service->file-service': 1,
+ 'order-service->firstleg-service': 3,
+ 'order-service->iam-service': 1,
+ 'order-service->warehouse-service': 1
+}))
+
+const expectedRawHttpImplementations = [
+ 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java',
+ 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java',
+ 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java',
+ 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java',
+ 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java',
+ 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java',
+ 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java',
+ 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java',
+ 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java'
+]
+
+const reviewedRawInternalEdges = [
+ 'event-bridge-service->firstleg-service',
+ 'event-bridge-service->lastleg-service',
+ 'gateway-overseas->auth-service',
+ 'lastleg-service->file-service',
+ 'lastleg-service->warehouse-overseas-service'
+]
+
+const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/
+
+async function javaFiles(directory) {
+ const result = []
+ async function visit(current) {
+ for (const entry of await readdir(current, { withFileTypes: true })) {
+ const target = path.join(current, entry.name)
+ if (entry.isDirectory()) await visit(target)
+ else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target)
+ }
+ }
+ await visit(directory)
+ return result
+}
+
+async function discoverFeignEdges() {
+ const edges = new Map()
+ for (const module of serviceModules) {
+ const sourceRoot = path.join(root, module, 'src', 'main', 'java')
+ for (const file of await javaFiles(sourceRoot)) {
+ const source = await readFile(file, 'utf8')
+ if (!source.includes('@FeignClient')) continue
+ const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/)
+ const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1]
+ assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`)
+ const edge = `${canonicalSource.get(module)}->${target}`
+ edges.set(edge, (edges.get(edge) ?? 0) + 1)
+ }
+ }
+ return edges
+}
+
+function sortedEntries(map) {
+ return [...map.entries()].sort(([left], [right]) => left.localeCompare(right))
+}
+
+function stronglyConnectedComponents(edgeNames) {
+ const adjacency = new Map()
+ for (const edge of edgeNames) {
+ const [from, to] = edge.split('->')
+ if (!adjacency.has(from)) adjacency.set(from, new Set())
+ if (!adjacency.has(to)) adjacency.set(to, new Set())
+ adjacency.get(from).add(to)
+ }
+ let index = 0
+ const indexes = new Map()
+ const lowLinks = new Map()
+ const stack = []
+ const onStack = new Set()
+ const components = []
+
+ function connect(node) {
+ indexes.set(node, index)
+ lowLinks.set(node, index++)
+ stack.push(node)
+ onStack.add(node)
+ for (const target of adjacency.get(node)) {
+ if (!indexes.has(target)) {
+ connect(target)
+ lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target)))
+ } else if (onStack.has(target)) {
+ lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target)))
+ }
+ }
+ if (lowLinks.get(node) !== indexes.get(node)) return
+ const component = []
+ let current
+ do {
+ current = stack.pop()
+ onStack.delete(current)
+ component.push(current)
+ } while (current !== node)
+ components.push(component.sort())
+ }
+
+ for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node)
+ return components.filter(component => component.length > 1)
+ .sort((left, right) => left.join().localeCompare(right.join()))
+}
+
+test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => {
+ assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges))
+})
+
+test('raw HTTP transports cannot be added without an explicit graph review', async () => {
+ const discovered = []
+ for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) {
+ for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) {
+ const source = await readFile(file, 'utf8')
+ if (!rawHttpPattern.test(source)) continue
+ if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue
+ discovered.push(path.relative(root, file).replaceAll('\\', '/'))
+ }
+ }
+ assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort())
+})
+
+test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => {
+ const feignEdges = (await discoverFeignEdges()).keys()
+ const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges])
+ assert.deepEqual(components, [[
+ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service',
+ 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service'
+ ]])
+})
+
+test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => {
+ for (const profile of ['local', 'cn', 'overseas']) {
+ const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8')
+ assert.match(source, /connect-timeout:\s*3000/)
+ assert.match(source, /read-timeout:\s*5000/)
+ }
+ const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8')
+ assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5)
+})