Merge remote-tracking branch 'origin/dev' into test
# Conflicts: # warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java # warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java # warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java # warehouse/src/main/resources/db/warehouse.sql
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
target/
|
target/
|
||||||
|
node_modules/
|
||||||
.docker-context/
|
.docker-context/
|
||||||
*.log
|
*.log
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
+57
-1
@@ -13,6 +13,55 @@ cache:
|
|||||||
paths:
|
paths:
|
||||||
- .m2/repository/
|
- .m2/repository/
|
||||||
|
|
||||||
|
Java拓扑合同:
|
||||||
|
stage: verify
|
||||||
|
image: node:22-alpine
|
||||||
|
cache: []
|
||||||
|
script:
|
||||||
|
- node --test scripts/java-topology-contract.test.mjs
|
||||||
|
|
||||||
|
OpenAPI有效性:
|
||||||
|
stage: verify
|
||||||
|
image: node:22-alpine
|
||||||
|
cache: []
|
||||||
|
script:
|
||||||
|
- npm ci --ignore-scripts --prefix scripts/openapi-validation
|
||||||
|
- npm test --prefix scripts/openapi-validation
|
||||||
|
- npm run validate --prefix scripts/openapi-validation
|
||||||
|
|
||||||
|
静态分析报告:
|
||||||
|
stage: verify
|
||||||
|
allow_failure: true
|
||||||
|
script:
|
||||||
|
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 7 days
|
||||||
|
paths:
|
||||||
|
- "**/target/spotbugs*.xml"
|
||||||
|
- "**/target/site/spotbugs.html"
|
||||||
|
|
||||||
|
质量摘要:
|
||||||
|
stage: verify
|
||||||
|
image: node:22-alpine
|
||||||
|
cache: []
|
||||||
|
allow_failure: true
|
||||||
|
needs:
|
||||||
|
- job: OpenAPI有效性
|
||||||
|
artifacts: false
|
||||||
|
- job: 校验与测试
|
||||||
|
artifacts: true
|
||||||
|
- job: 静态分析报告
|
||||||
|
artifacts: true
|
||||||
|
script:
|
||||||
|
- node --test scripts/java-quality-summary.test.mjs
|
||||||
|
- node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 7 days
|
||||||
|
paths:
|
||||||
|
- target/java-quality-summary.json
|
||||||
|
|
||||||
校验与测试:
|
校验与测试:
|
||||||
stage: verify
|
stage: verify
|
||||||
script:
|
script:
|
||||||
@@ -25,10 +74,14 @@ cache:
|
|||||||
- "**/target/surefire-reports/TEST-*.xml"
|
- "**/target/surefire-reports/TEST-*.xml"
|
||||||
paths:
|
paths:
|
||||||
- "**/target/surefire-reports/"
|
- "**/target/surefire-reports/"
|
||||||
|
- "**/target/site/jacoco/"
|
||||||
|
- "**/target/jacoco.exec"
|
||||||
|
|
||||||
打包服务:
|
打包服务:
|
||||||
stage: package
|
stage: package
|
||||||
needs:
|
needs:
|
||||||
|
- Java拓扑合同
|
||||||
|
- OpenAPI有效性
|
||||||
- 校验与测试
|
- 校验与测试
|
||||||
script:
|
script:
|
||||||
- mvn $MAVEN_CLI_OPTS -DskipTests package
|
- mvn $MAVEN_CLI_OPTS -DskipTests package
|
||||||
@@ -36,14 +89,17 @@ cache:
|
|||||||
expire_in: 7 days
|
expire_in: 7 days
|
||||||
paths:
|
paths:
|
||||||
- gateway/target/*.jar
|
- gateway/target/*.jar
|
||||||
|
- gateway-overseas/target/*.jar
|
||||||
- auth/target/*.jar
|
- auth/target/*.jar
|
||||||
- iam/target/*.jar
|
- iam/target/*.jar
|
||||||
- order/target/*.jar
|
- order/target/*.jar
|
||||||
- firstleg/target/*.jar
|
- firstleg/target/*.jar
|
||||||
|
- finance/target/*.jar
|
||||||
- warehouse/target/*.jar
|
- warehouse/target/*.jar
|
||||||
|
- warehouse-overseas-service/target/*.jar
|
||||||
|
- lastleg-service/target/*.jar
|
||||||
- file/target/*.jar
|
- file/target/*.jar
|
||||||
- print/target/*.jar
|
- print/target/*.jar
|
||||||
- audit-service/target/*.jar
|
- audit-service/target/*.jar
|
||||||
- integration-service/target/*.jar
|
- integration-service/target/*.jar
|
||||||
- event-bridge-service/target/*.jar
|
- event-bridge-service/target/*.jar
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.ResultSetMetaData;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
public final class DbQuery {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
if (args.length < 2) {
|
||||||
|
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
|
||||||
|
}
|
||||||
|
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
|
||||||
|
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
|
||||||
|
+ "&useSSL=false&allowPublicKeyRetrieval=true";
|
||||||
|
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
|
||||||
|
Statement statement = connection.createStatement()) {
|
||||||
|
boolean hasResult = statement.execute(args[1]);
|
||||||
|
if (!hasResult) {
|
||||||
|
System.out.println("updateCount=" + statement.getUpdateCount());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (ResultSet result = statement.getResultSet()) {
|
||||||
|
ResultSetMetaData meta = result.getMetaData();
|
||||||
|
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||||
|
if (column > 1) System.out.print("\t");
|
||||||
|
System.out.print(meta.getColumnLabel(column));
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
while (result.next()) {
|
||||||
|
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||||
|
if (column > 1) System.out.print("\t");
|
||||||
|
System.out.print(String.valueOf(result.getObject(column)));
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
# 国内头程后端项目指引
|
# 头程与海外仓后端项目指引
|
||||||
|
|
||||||
## 适用范围与定位
|
## 适用范围与定位
|
||||||
|
|
||||||
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
||||||
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
|
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
|
||||||
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
|
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
|
||||||
|
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
|
||||||
|
|
||||||
## 不可突破的产品与数据边界
|
## 不可突破的产品与数据边界
|
||||||
|
|
||||||
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
||||||
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
||||||
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
|
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
||||||
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
||||||
|
|
||||||
## 服务路由(按关键词定位)
|
## 服务路由(按关键词定位)
|
||||||
@@ -19,12 +20,16 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||||
|
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||||
|
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||||
|
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||||
|
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||||
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||||
@@ -32,7 +37,29 @@
|
|||||||
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
||||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||||
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
|
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||||
|
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||||
|
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||||
|
| 海外入仓分页、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 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../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/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
|
||||||
|
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
## 公共模块快速索引
|
||||||
|
|
||||||
|
| 关键词 | 模块与入口 |
|
||||||
|
|---|---|
|
||||||
|
| 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 |
|
||||||
|
| 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 |
|
||||||
|
| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 |
|
||||||
|
| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
|
||||||
|
| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
|
||||||
|
| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
|
||||||
|
| 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` |
|
||||||
|
|
||||||
## 全量验收路由
|
## 全量验收路由
|
||||||
|
|
||||||
@@ -43,7 +70,8 @@
|
|||||||
|
|
||||||
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
||||||
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
|
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
|
||||||
- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
||||||
|
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
|
||||||
|
|
||||||
## 契约、数据库与验证入口
|
## 契约、数据库与验证入口
|
||||||
|
|
||||||
@@ -53,8 +81,11 @@
|
|||||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
|
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
|
||||||
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
|
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||||
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|
||||||
## 全量 HTTP 验收脚本路由
|
## 全量 HTTP 验收脚本路由
|
||||||
|
|
||||||
@@ -62,6 +93,7 @@
|
|||||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||||
|
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||||
|
|
||||||
## 阶段文档入口
|
## 阶段文档入口
|
||||||
@@ -69,6 +101,7 @@
|
|||||||
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
|
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
|
||||||
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||||
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
||||||
|
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||||
|
|
||||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||||
|
|||||||
Vendored
+181
-24
@@ -1,10 +1,11 @@
|
|||||||
def serviceModules = [
|
def serviceModules = [
|
||||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
'integration-service', 'event-bridge-service', 'audit-service'
|
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||||
]
|
]
|
||||||
|
|
||||||
def serviceCheckboxes = [
|
def serviceCheckboxes = [
|
||||||
'gateway': 'SERVICE_GATEWAY',
|
'gateway': 'SERVICE_GATEWAY',
|
||||||
|
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
|
||||||
'auth': 'SERVICE_AUTH',
|
'auth': 'SERVICE_AUTH',
|
||||||
'iam': 'SERVICE_IAM',
|
'iam': 'SERVICE_IAM',
|
||||||
'firstleg': 'SERVICE_FIRSTLEG',
|
'firstleg': 'SERVICE_FIRSTLEG',
|
||||||
@@ -14,26 +15,95 @@ def serviceCheckboxes = [
|
|||||||
'file': 'SERVICE_FILE',
|
'file': 'SERVICE_FILE',
|
||||||
'print': 'SERVICE_PRINT',
|
'print': 'SERVICE_PRINT',
|
||||||
'integration-service': 'SERVICE_INTEGRATION',
|
'integration-service': 'SERVICE_INTEGRATION',
|
||||||
|
'lastleg-service': 'SERVICE_LASTLEG',
|
||||||
|
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
|
||||||
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
|
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
|
||||||
'audit-service': 'SERVICE_AUDIT'
|
'audit-service': 'SERVICE_AUDIT'
|
||||||
]
|
]
|
||||||
|
|
||||||
def commonPaths = [
|
def fullRebuildPaths = [
|
||||||
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
|
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
|
||||||
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
|
'deploy/docker-compose.yml', 'deploy/service-map.yml'
|
||||||
'Jenkinsfile.service', 'deploy/'
|
]
|
||||||
|
|
||||||
|
def commonPathServices = [
|
||||||
|
'common-web/': [
|
||||||
|
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
|
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
|
||||||
|
'event-bridge-service', 'audit-service'
|
||||||
|
],
|
||||||
|
'common-security/': [
|
||||||
|
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
|
||||||
|
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
|
||||||
|
],
|
||||||
|
'common-openapi/': [
|
||||||
|
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
|
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
|
||||||
|
'event-bridge-service', 'audit-service'
|
||||||
|
],
|
||||||
|
'common-audit/': [
|
||||||
|
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
|
||||||
|
'warehouse-overseas-service', 'audit-service'
|
||||||
|
],
|
||||||
|
'common-event-contract/': [
|
||||||
|
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
|
||||||
|
],
|
||||||
|
'deploy/docker-compose.overseas.yml': [
|
||||||
|
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
|
||||||
|
],
|
||||||
|
'deploy/docker-compose.audit.yml': ['audit-service'],
|
||||||
|
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
|
||||||
]
|
]
|
||||||
|
|
||||||
def databaseMigrationPaths = ['deploy/sql/']
|
def databaseMigrationPaths = ['deploy/sql/']
|
||||||
|
|
||||||
def downstreamResults = [:]
|
def downstreamResults = [:]
|
||||||
|
|
||||||
|
def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||||
|
String marker = ' | RESULTS'
|
||||||
|
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
|
||||||
|
int markerIndex = description.indexOf(marker)
|
||||||
|
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
||||||
|
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
||||||
|
String summary = description.substring(markerIndex + marker.length()).trim()
|
||||||
|
List<String> failedServices = []
|
||||||
|
if (summary) {
|
||||||
|
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||||
|
int separator = item.indexOf('=')
|
||||||
|
if (separator > 0) {
|
||||||
|
String service = item.substring(0, separator).trim()
|
||||||
|
String result = item.substring(separator + 1).trim()
|
||||||
|
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [commit: commit, failedServices: failedServices.unique()]
|
||||||
|
}
|
||||||
|
|
||||||
|
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||||
|
def candidate = previousBuild
|
||||||
|
int inspected = 0
|
||||||
|
while (candidate != null && inspected < 30) {
|
||||||
|
def state = parseAutoBuildState(candidate.description, allowedServices)
|
||||||
|
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
|
||||||
|
candidate = candidate.previousBuild
|
||||||
|
inspected++
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
def environmentChoicesForJob(String jobBaseName) {
|
def environmentChoicesForJob(String jobBaseName) {
|
||||||
return jobBaseName == 'backend-java-dev'
|
return jobBaseName == 'backend-java-dev'
|
||||||
? ['development', 'test', 'staging', 'production']
|
? ['development', 'test', 'staging', 'production']
|
||||||
: ['test', 'development', 'staging', 'production']
|
: ['test', 'development', 'staging', 'production']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def downstreamJobPrefixForJob(String jobBaseName) {
|
||||||
|
return jobBaseName == 'backend-java-dev'
|
||||||
|
? 'baiyue-firstleg-dev-'
|
||||||
|
: 'baiyue-firstleg-'
|
||||||
|
}
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
|
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
|
||||||
agent none
|
agent none
|
||||||
@@ -47,6 +117,7 @@ pipeline {
|
|||||||
parameters {
|
parameters {
|
||||||
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
|
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
|
||||||
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
|
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
|
||||||
|
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
|
||||||
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
||||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
||||||
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
||||||
@@ -56,14 +127,17 @@ pipeline {
|
|||||||
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
||||||
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
|
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
|
||||||
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
|
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
|
||||||
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
|
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
|
||||||
|
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
|
||||||
|
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)')
|
||||||
|
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑')
|
||||||
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
|
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
|
||||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth')
|
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
@@ -95,7 +169,12 @@ pipeline {
|
|||||||
}
|
}
|
||||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||||
env.DATABASE_CHANGE_DETECTED = 'false'
|
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||||
|
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||||
|
error('生产环境禁止跳过测试')
|
||||||
|
}
|
||||||
|
|
||||||
|
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||||
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
List selected = []
|
List selected = []
|
||||||
if (params.BUILD_MODE == 'ALL') {
|
if (params.BUILD_MODE == 'ALL') {
|
||||||
selected = serviceModules
|
selected = serviceModules
|
||||||
@@ -107,9 +186,17 @@ pipeline {
|
|||||||
error('SELECT 模式至少需要勾选一个服务')
|
error('SELECT 模式至少需要勾选一个服务')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
|
||||||
|
String base = autoState?.commit?.trim()
|
||||||
|
List retryServices = autoState?.failedServices ?: []
|
||||||
|
if (!base) {
|
||||||
|
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
|
||||||
|
// SELECT/ALL 不会推进 AUTO 基线。
|
||||||
|
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
||||||
|
}
|
||||||
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
||||||
selected = serviceModules
|
selected = serviceModules
|
||||||
|
echo 'AUTO 未找到可用基线,安全回退为全量构建'
|
||||||
} else {
|
} else {
|
||||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||||
@@ -117,13 +204,27 @@ pipeline {
|
|||||||
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
}
|
}
|
||||||
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||||
boolean commonChanged = changed.any { path ->
|
List serviceRelevantChanges = changed.findAll { path ->
|
||||||
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
|
||||||
}
|
}
|
||||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
boolean fullRebuild = serviceRelevantChanges.any { path ->
|
||||||
changed.any { it == module || it.startsWith("${module}/") }
|
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||||
}
|
}
|
||||||
|
if (fullRebuild) {
|
||||||
|
selected = serviceModules
|
||||||
|
} else {
|
||||||
|
selected = serviceModules.findAll { module ->
|
||||||
|
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
|
||||||
|
}
|
||||||
|
commonPathServices.each { prefix, impactedServices ->
|
||||||
|
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
|
||||||
|
selected.addAll(impactedServices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selected.addAll(retryServices)
|
||||||
|
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
|
||||||
|
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,17 +234,32 @@ pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected = selected.unique()
|
selected = selected.unique()
|
||||||
|
if (params.DEPLOY && selected.contains('event-bridge-service')) {
|
||||||
|
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||||
|
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||||
|
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||||
|
}
|
||||||
|
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
|
||||||
|
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
|
||||||
|
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||||
|
}
|
||||||
|
if (bridgeRegion == 'SELECT') {
|
||||||
|
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
|
||||||
|
}
|
||||||
|
}
|
||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
? '仅数据库迁移变更 | 需单独审核执行'
|
? "AUTO ${env.IMAGE_TAG} | RESULTS"
|
||||||
: '没有需要构建的业务服务'
|
: (env.DATABASE_CHANGE_DETECTED == 'true'
|
||||||
|
? '仅数据库迁移变更 | 需单独审核执行'
|
||||||
|
: '没有需要构建的业务服务')
|
||||||
} else {
|
} else {
|
||||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
|
||||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
|
||||||
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
|
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
|
||||||
|
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||||
}
|
}
|
||||||
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||||
@@ -164,6 +280,33 @@ pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stage('统一 Maven 测试门禁') {
|
||||||
|
when {
|
||||||
|
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||||
|
}
|
||||||
|
agent { label 'java-builder' }
|
||||||
|
tools { maven 'maven-3.8.8' }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
String selectedModules = env.SELECTED_SERVICES.split(',')
|
||||||
|
.collect { it.trim() }
|
||||||
|
.findAll { it }
|
||||||
|
.join(',')
|
||||||
|
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||||
|
sh """
|
||||||
|
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||||
|
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||||
|
"""
|
||||||
|
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stage('并行调度独立服务 Job') {
|
stage('并行调度独立服务 Job') {
|
||||||
when { expression { env.NO_SERVICE_CHANGE != 'true' } }
|
when { expression { env.NO_SERVICE_CHANGE != 'true' } }
|
||||||
steps {
|
steps {
|
||||||
@@ -173,6 +316,14 @@ pipeline {
|
|||||||
String service = selectedService
|
String service = selectedService
|
||||||
branches[service] = {
|
branches[service] = {
|
||||||
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
|
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
|
||||||
|
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||||
|
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
|
||||||
|
String downstreamImageTag = deployBothBridges
|
||||||
|
? "${env.IMAGE_TAG}-both"
|
||||||
|
: env.IMAGE_TAG
|
||||||
|
String downstreamBridgeRegion = deployBothBridges
|
||||||
|
? 'CN'
|
||||||
|
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
|
||||||
echo "触发单服务 Job: ${jobName}"
|
echo "触发单服务 Job: ${jobName}"
|
||||||
def downstreamBuild = build(
|
def downstreamBuild = build(
|
||||||
job: jobName,
|
job: jobName,
|
||||||
@@ -180,12 +331,16 @@ pipeline {
|
|||||||
propagate: false,
|
propagate: false,
|
||||||
parameters: [
|
parameters: [
|
||||||
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
|
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
|
||||||
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
|
string(name: 'IMAGE_TAG', value: downstreamImageTag),
|
||||||
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
|
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
|
||||||
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
|
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
|
||||||
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
|
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
|
||||||
|
booleanParam(name: 'SKIP_TESTS', value: true),
|
||||||
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
|
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
|
||||||
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
|
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
|
||||||
|
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
|
||||||
|
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
|
||||||
|
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
|
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
|
||||||
@@ -205,7 +360,9 @@ pipeline {
|
|||||||
.collect { service, result -> "${service}=${result}" }
|
.collect { service, result -> "${service}=${result}" }
|
||||||
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
|
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
|
||||||
echo "单服务执行汇总: ${summary}"
|
echo "单服务执行汇总: ${summary}"
|
||||||
currentBuild.description = "${env.IMAGE_TAG} | ${summary}"
|
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||||
|
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||||
|
: "${env.IMAGE_TAG} | ${summary}"
|
||||||
if (failed) {
|
if (failed) {
|
||||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-35
@@ -1,10 +1,11 @@
|
|||||||
def serviceModules = [
|
def serviceModules = [
|
||||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
'integration-service', 'event-bridge-service', 'audit-service'
|
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||||
]
|
]
|
||||||
|
|
||||||
def serviceJars = [
|
def serviceJars = [
|
||||||
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
|
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
|
||||||
|
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
|
||||||
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
||||||
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
||||||
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
||||||
@@ -14,6 +15,8 @@ def serviceJars = [
|
|||||||
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
|
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
|
||||||
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
|
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
|
||||||
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
|
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
|
||||||
|
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
|
||||||
|
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
|
||||||
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
|
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
|
||||||
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
||||||
]
|
]
|
||||||
@@ -35,6 +38,7 @@ pipeline {
|
|||||||
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||||
|
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,11 @@ pipeline {
|
|||||||
if (!serviceModules.contains(service)) {
|
if (!serviceModules.contains(service)) {
|
||||||
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
||||||
}
|
}
|
||||||
|
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||||
|
imageTag?.endsWith('-both')
|
||||||
|
if (deployBothBridges) {
|
||||||
|
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
|
||||||
|
}
|
||||||
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||||||
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
||||||
}
|
}
|
||||||
@@ -89,6 +98,18 @@ pipeline {
|
|||||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||||
env.RESOLVED_IMAGE_TAG = imageTag
|
env.RESOLVED_IMAGE_TAG = imageTag
|
||||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||||
|
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
|
||||||
|
|
||||||
|
if (service == 'event-bridge-service' && params.DEPLOY) {
|
||||||
|
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||||
|
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||||
|
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||||
|
}
|
||||||
|
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
|
||||||
|
!['development', 'test'].contains(params.ENVIRONMENT)) {
|
||||||
|
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sh '''
|
sh '''
|
||||||
@@ -110,12 +131,15 @@ pipeline {
|
|||||||
stage('Maven 测试与打包') {
|
stage('Maven 测试与打包') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
|
||||||
error('生产环境禁止跳过测试')
|
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
|
||||||
|
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
|
||||||
}
|
}
|
||||||
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||||
if (params.ENVIRONMENT == 'test') {
|
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
|
||||||
|
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
|
||||||
|
} else if (params.ENVIRONMENT == 'test') {
|
||||||
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||||
}
|
}
|
||||||
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||||
@@ -135,16 +159,21 @@ pipeline {
|
|||||||
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
|
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
|
||||||
String jar = serviceJars[env.RESOLVED_SERVICE]
|
String jar = serviceJars[env.RESOLVED_SERVICE]
|
||||||
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
||||||
|
String dockerConfigDir = "${pwd(tmp: true)}/docker-auth"
|
||||||
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
|
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
|
||||||
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
|
|
||||||
|
|
||||||
echo "推送共享镜像: ${image}"
|
echo "推送共享镜像: ${image}"
|
||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
withEnv(["DOCKER_CONFIG=${dockerConfigDir}"]) {
|
||||||
try {
|
sh 'mkdir -p "$DOCKER_CONFIG"'
|
||||||
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
|
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||||
sh "docker push ${image}"
|
try {
|
||||||
} finally {
|
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
|
||||||
sh "docker logout ${registryHost} || true"
|
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
|
||||||
|
sh "docker push ${image}"
|
||||||
|
} finally {
|
||||||
|
sh "docker logout ${registryHost} || true"
|
||||||
|
sh 'rm -rf "$DOCKER_CONFIG"'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (params.DEPLOY) {
|
if (params.DEPLOY) {
|
||||||
@@ -161,36 +190,52 @@ pipeline {
|
|||||||
beforeAgent true
|
beforeAgent true
|
||||||
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
|
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
|
||||||
}
|
}
|
||||||
agent {
|
agent none
|
||||||
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'}"
|
|
||||||
}
|
|
||||||
steps {
|
steps {
|
||||||
deleteDir()
|
|
||||||
unstash 'deployment-files'
|
|
||||||
script {
|
script {
|
||||||
String deployLock = "firstleg-${params.ENVIRONMENT}-deploy"
|
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
|
||||||
|
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
|
||||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
||||||
lock(resource: deployLock, inversePrecedence: true) {
|
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
||||||
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
|
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
||||||
withEnv([
|
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
|
||||||
'TARGET_IS_LOCAL=true'
|
lock(resource: serviceDeployResource, inversePrecedence: true) {
|
||||||
]) {
|
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
|
||||||
withCredentials([
|
node(targetLabel) {
|
||||||
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
|
deleteDir()
|
||||||
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
|
try {
|
||||||
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
|
unstash 'deployment-files'
|
||||||
]) {
|
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
|
||||||
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
|
||||||
|
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
|
||||||
|
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
|
||||||
|
? ['CN', 'OVERSEAS']
|
||||||
|
: [params.EVENT_BRIDGE_REGION]
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
|
||||||
|
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
|
||||||
|
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
|
||||||
|
]) {
|
||||||
|
bridgeRegions.each { bridgeRegion ->
|
||||||
|
if (env.RESOLVED_SERVICE == 'event-bridge-service') {
|
||||||
|
echo "部署事件桥实例: ${bridgeRegion}"
|
||||||
|
}
|
||||||
|
withEnv([
|
||||||
|
'TARGET_IS_LOCAL=true',
|
||||||
|
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
|
||||||
|
]) {
|
||||||
|
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post {
|
|
||||||
always {
|
|
||||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('部署单个服务') {
|
stage('部署单个服务') {
|
||||||
@@ -213,7 +258,10 @@ pipeline {
|
|||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||||
try {
|
try {
|
||||||
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
|
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
|
||||||
withEnv(["DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}"]) {
|
withEnv([
|
||||||
|
"DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}",
|
||||||
|
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
|
||||||
|
]) {
|
||||||
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
|
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
|
||||||
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 柏悦头程平台
|
# 柏悦头程平台
|
||||||
|
|
||||||
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
|
||||||
|
|
||||||
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
||||||
|
|
||||||
@@ -12,20 +12,24 @@
|
|||||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||||
- `warehouse`:国内仓储能力。
|
- `warehouse`:国内仓储能力。
|
||||||
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
|
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
|
||||||
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
|
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。
|
||||||
|
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
|
||||||
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
|
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
|
||||||
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
|
- `platform-bom`:统一管理仓库内公共依赖版本。
|
||||||
|
|
||||||
## 本地构建
|
## 本地构建
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mvn -DskipTests validate
|
mvn -DskipTests validate
|
||||||
|
node --test scripts/java-topology-contract.test.mjs
|
||||||
mvn test
|
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`。
|
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 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
国内 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,也不允许通过公网直接调用对方内部服务。
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||||
|
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
||||||
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
|
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
|
||||||
|
|
||||||
## 模块入口
|
## 模块入口
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||||
| 海外访问 Token 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 |
|
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||||
|
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 国内与海外 JWT/JWKS 冻结契约
|
||||||
|
|
||||||
|
## 双轨边界
|
||||||
|
|
||||||
|
### 国内现有链路
|
||||||
|
|
||||||
|
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
|
||||||
|
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
|
||||||
|
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
|
||||||
|
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
|
||||||
|
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
|
||||||
|
|
||||||
|
### 海外尾程链路
|
||||||
|
|
||||||
|
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
|
||||||
|
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
|
||||||
|
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
|
||||||
|
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
|
||||||
|
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
|
||||||
|
|
||||||
|
## 海外签发契约
|
||||||
|
|
||||||
|
- `issuer`:`auth-service`
|
||||||
|
- `audience`:`lastleg-api`
|
||||||
|
- 算法白名单:仅 `RS256`
|
||||||
|
- Access Token 默认有效期:15 分钟
|
||||||
|
- 兑换接口:`POST /api/auth/overseas/token`
|
||||||
|
- JWKS 路径:`/api/auth/.well-known/jwks.json`
|
||||||
|
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
|
||||||
|
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
|
||||||
|
|
||||||
|
海外 Token 的 JSON 声明名称和类型固定如下:
|
||||||
|
|
||||||
|
| 声明 | JSON 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sub` | string | 用户 ID 的字符串形式 |
|
||||||
|
| `jti` | string | Token 唯一标识 |
|
||||||
|
| `iat` | number | 签发时间 |
|
||||||
|
| `exp` | number | 过期时间 |
|
||||||
|
| `aud` | string array | 固定包含 `lastleg-api` |
|
||||||
|
| `userId` | number | 认证用户 ID |
|
||||||
|
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
|
||||||
|
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
|
||||||
|
| `roles` | string array | 当前角色编码快照 |
|
||||||
|
| `authorities` | string array | 当前权限编码快照 |
|
||||||
|
| `permissionVersion` | number | IAM 单调权限版本 |
|
||||||
|
|
||||||
|
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
|
||||||
|
|
||||||
|
## 海外配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
baiyue:
|
||||||
|
security:
|
||||||
|
overseas-jwt:
|
||||||
|
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||||
|
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||||
|
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||||
|
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||||
|
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||||
|
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||||
|
```
|
||||||
|
|
||||||
|
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
|
||||||
|
|
||||||
|
## 密钥轮换
|
||||||
|
|
||||||
|
- JWK Set 只允许 RSA 密钥。
|
||||||
|
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
|
||||||
|
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
|
||||||
|
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
|
||||||
|
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
|
||||||
|
|
||||||
|
## IAM 安全事件
|
||||||
|
|
||||||
|
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
|
||||||
@@ -20,4 +20,13 @@ public class AuthorizationSnapshot {
|
|||||||
private String clientType;
|
private String clientType;
|
||||||
private Set<String> roles;
|
private Set<String> roles;
|
||||||
private Set<String> permissions;
|
private Set<String> permissions;
|
||||||
|
private Long permissionVersion;
|
||||||
|
private Long overseasWarehouseId;
|
||||||
|
|
||||||
|
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||||
|
String clientType, Set<String> roles, Set<String> permissions,
|
||||||
|
Long permissionVersion) {
|
||||||
|
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||||
|
permissionVersion, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ public interface IamOnboardingClient {
|
|||||||
String legalPerson, String legalPhone, String contactEmail,
|
String legalPerson, String legalPhone, String contactEmail,
|
||||||
String countryCode, String address) { }
|
String countryCode, String address) { }
|
||||||
|
|
||||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
|
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||||
|
String dimensionInitializationStatus) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.baiyue.auth.config;
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
|
||||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
|||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||||
@@ -31,6 +27,7 @@ public class AuthSecurityConfig {
|
|||||||
"/api/auth/refresh",
|
"/api/auth/refresh",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/api/auth/impersonations/exchange",
|
"/api/auth/impersonations/exchange",
|
||||||
|
"/api/auth/.well-known/jwks.json",
|
||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/swagger-ui/**",
|
"/swagger-ui/**",
|
||||||
@@ -40,13 +37,6 @@ public class AuthSecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||||
|
|
||||||
@Bean
|
|
||||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
|
||||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
|
||||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||||
JwtAuthenticationFilter jwtFilter,
|
JwtAuthenticationFilter jwtFilter,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class DomesticJwtConfiguration {
|
||||||
|
@Bean("jwtEncoder")
|
||||||
|
@Primary
|
||||||
|
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||||
|
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||||
|
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableConfigurationProperties(OverseasJwtProperties.class)
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class OverseasJwtConfiguration {
|
||||||
|
@Bean("overseasJwtEncoder")
|
||||||
|
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
|
||||||
|
try {
|
||||||
|
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
|
||||||
|
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
|
||||||
|
if (current == null || !current.isPrivate()) {
|
||||||
|
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
|
||||||
|
}
|
||||||
|
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
|
||||||
|
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
|
||||||
|
}
|
||||||
|
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
|
||||||
|
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
|
||||||
|
throw new IllegalStateException("海外非当前密钥只能配置公钥");
|
||||||
|
}
|
||||||
|
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
|
||||||
|
} catch (java.text.ParseException exception) {
|
||||||
|
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
|
||||||
|
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
|
||||||
|
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
|
||||||
|
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
|
||||||
@@ -23,6 +23,8 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
|||||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos;
|
import com.baiyue.auth.dto.AccountManagementDtos;
|
||||||
import com.baiyue.auth.service.AccountManagementService;
|
import com.baiyue.auth.service.AccountManagementService;
|
||||||
|
import com.baiyue.auth.service.OverseasTokenService;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +40,18 @@ public class AuthController {
|
|||||||
private MerchantRegistrationService registrationService;
|
private MerchantRegistrationService registrationService;
|
||||||
@Resource
|
@Resource
|
||||||
private AccountManagementService accountManagementService;
|
private AccountManagementService accountManagementService;
|
||||||
|
@Resource
|
||||||
|
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||||
|
|
||||||
|
@PostMapping("/overseas/token")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
|
||||||
|
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
|
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||||
|
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
|
||||||
|
return ApiResponse.success(service.exchange(user.userId()));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
|
|||||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||||
import com.baiyue.auth.service.AccountManagementService;
|
import com.baiyue.auth.service.AccountManagementService;
|
||||||
import com.baiyue.auth.service.AuthService;
|
import com.baiyue.auth.service.AuthService;
|
||||||
|
import com.baiyue.auth.service.OverseasSessionValidationService;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
|
|||||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/auth/accounts")
|
@RequestMapping("/internal/auth/accounts")
|
||||||
|
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||||
public class InternalAccountController {
|
public class InternalAccountController {
|
||||||
@Resource
|
@Resource
|
||||||
private AccountManagementService service;
|
private AccountManagementService service;
|
||||||
@Resource
|
@Resource
|
||||||
private AuthService authService;
|
private AuthService authService;
|
||||||
|
@Resource
|
||||||
|
private OverseasSessionValidationService overseasSessionValidationService;
|
||||||
|
|
||||||
@Value("${baiyue.security.internal-service-token}")
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
private String configuredToken;
|
private String configuredToken;
|
||||||
@@ -64,6 +71,15 @@ public class InternalAccountController {
|
|||||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
@PostMapping("/overseas-session/validate")
|
||||||
|
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
|
||||||
|
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
|
||||||
|
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
|
||||||
|
verify(token);
|
||||||
|
return ApiResponse.success(overseasSessionValidationService.validate(request));
|
||||||
|
}
|
||||||
@PutMapping("/{id}/profile")
|
@PutMapping("/{id}/profile")
|
||||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class JwksController {
|
||||||
|
private final OverseasJwtProperties properties;
|
||||||
|
|
||||||
|
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
|
||||||
|
|
||||||
|
@GetMapping("/api/auth/.well-known/jwks.json")
|
||||||
|
@SecurityRequirements
|
||||||
|
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
|
||||||
|
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
|
||||||
|
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
|
|||||||
Long tenantId,
|
Long tenantId,
|
||||||
Long membershipId,
|
Long membershipId,
|
||||||
String tenantStatus,
|
String tenantStatus,
|
||||||
|
String dimensionInitializationStatus,
|
||||||
String nextStep
|
String nextStep
|
||||||
) { }
|
) { }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.auth.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
|
||||||
|
public final class OverseasSessionValidationDtos {
|
||||||
|
private OverseasSessionValidationDtos() { }
|
||||||
|
|
||||||
|
@Schema(name = "OverseasSessionValidationRequest",
|
||||||
|
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
|
||||||
|
public record Request(
|
||||||
|
@NotNull Long userId,
|
||||||
|
@NotNull Integer tokenVersion,
|
||||||
|
Long overseasWarehouseId) { }
|
||||||
|
|
||||||
|
@Schema(name = "OverseasSessionValidationResponse")
|
||||||
|
public record Response(boolean valid, FailureReason failureReason) {
|
||||||
|
public static Response accepted() { return new Response(true, null); }
|
||||||
|
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FailureReason {
|
||||||
|
ACCOUNT_INVALID,
|
||||||
|
TOKEN_VERSION_MISMATCH,
|
||||||
|
WAREHOUSE_UNBOUND,
|
||||||
|
WAREHOUSE_MISMATCH
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ public record TokenResponse(
|
|||||||
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
|
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
|
||||||
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
||||||
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
||||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
|
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
|
||||||
|
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
|
||||||
|
format = "password", nullable = true) String overseasAccessToken,
|
||||||
|
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
|
||||||
|
example = "900", minimum = "0") long overseasExpiresIn
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
|||||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -49,6 +50,8 @@ public class AuthService {
|
|||||||
private AuthorizationLoader authorizationLoader;
|
private AuthorizationLoader authorizationLoader;
|
||||||
@Resource
|
@Resource
|
||||||
private TokenSessionStore tokenSessionStore;
|
private TokenSessionStore tokenSessionStore;
|
||||||
|
@Resource
|
||||||
|
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||||
|
|
||||||
private final SecureRandom secureRandom = new SecureRandom();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ public class AuthService {
|
|||||||
Instant issuedAt = Instant.now();
|
Instant issuedAt = Instant.now();
|
||||||
String jwtId = UUID.randomUUID().toString();
|
String jwtId = UUID.randomUUID().toString();
|
||||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
.subject(account.getId().toString())
|
.subject(account.getId().toString())
|
||||||
@@ -125,8 +128,11 @@ public class AuthService {
|
|||||||
.claim("userId", account.getId())
|
.claim("userId", account.getId())
|
||||||
.claim("tenantId", account.getDefaultTenantId())
|
.claim("tenantId", account.getDefaultTenantId())
|
||||||
.claim("appCode", account.getDefaultAppCode())
|
.claim("appCode", account.getDefaultAppCode())
|
||||||
.claim("permissionVersion", account.getTokenVersion())
|
.claim("permissionVersion", account.getTokenVersion());
|
||||||
.build();
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
|
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||||
|
}
|
||||||
|
JwtClaimsSet claims = claimsBuilder.build();
|
||||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||||
signingHeader(), claims)).getTokenValue();
|
signingHeader(), claims)).getTokenValue();
|
||||||
|
|
||||||
@@ -135,8 +141,13 @@ public class AuthService {
|
|||||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||||
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
||||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||||
|
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||||
|
OverseasTokenService.OverseasTokenResponse overseas = service == null
|
||||||
|
? null : service.issue(account, authorization);
|
||||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
rawRefresh, properties.refreshTokenTtl().toSeconds(),
|
||||||
|
overseas == null ? null : overseas.accessToken(),
|
||||||
|
overseas == null ? 0L : overseas.expiresIn());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ public class MerchantRegistrationService {
|
|||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||||
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||||
|
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
|
||||||
|
@Service
|
||||||
|
public class OverseasSessionValidationService {
|
||||||
|
@Resource private AuthAccountRepository accounts;
|
||||||
|
@Resource private AuthorizationLoader authorizationLoader;
|
||||||
|
|
||||||
|
public Response validate(Request request) {
|
||||||
|
AuthAccount account = accounts.findById(request.userId());
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||||
|
return Response.rejected(FailureReason.ACCOUNT_INVALID);
|
||||||
|
}
|
||||||
|
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
|
||||||
|
if (currentVersion != request.tokenVersion()) {
|
||||||
|
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
|
||||||
|
}
|
||||||
|
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
|
||||||
|
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
|
||||||
|
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
|
||||||
|
}
|
||||||
|
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
|
||||||
|
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
|
||||||
|
}
|
||||||
|
return Response.accepted();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class OverseasTokenService {
|
||||||
|
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
|
||||||
|
"platform:overseas-warehouse:",
|
||||||
|
"platform:overseas-business:",
|
||||||
|
"platform:overseas-master-data:",
|
||||||
|
"lastleg:"
|
||||||
|
);
|
||||||
|
|
||||||
|
@Resource private AuthAccountRepository accounts;
|
||||||
|
@Resource private AuthorizationLoader authorizationLoader;
|
||||||
|
@Resource private OverseasJwtProperties properties;
|
||||||
|
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
|
||||||
|
|
||||||
|
public OverseasTokenResponse exchange(long authenticatedUserId) {
|
||||||
|
AuthAccount account = accounts.findById(authenticatedUserId);
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||||
|
}
|
||||||
|
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||||
|
return issue(account, authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
|
||||||
|
Instant issuedAt = Instant.now();
|
||||||
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
|
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||||
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
|
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||||
|
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
|
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
|
||||||
|
.claim("membership_id", authorization.getMembershipId())
|
||||||
|
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||||
|
.claim("roles", authorization.getRoles())
|
||||||
|
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
||||||
|
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
||||||
|
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||||
|
if (authorization.getOverseasWarehouseId() != null) {
|
||||||
|
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||||
|
}
|
||||||
|
JwtClaimsSet claims = claimsBuilder.build();
|
||||||
|
String token = encoder.encode(JwtEncoderParameters.from(
|
||||||
|
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||||
|
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> overseasAuthorities(Set<String> permissions) {
|
||||||
|
if (permissions == null || permissions.isEmpty()) return Set.of();
|
||||||
|
return permissions.stream()
|
||||||
|
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith))
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||||
|
}
|
||||||
@@ -39,6 +39,13 @@ baiyue.security:
|
|||||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||||
|
overseas-jwt:
|
||||||
|
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||||
|
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||||
|
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||||
|
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||||
|
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||||
|
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||||
|
|
||||||
baiyue.clients:
|
baiyue.clients:
|
||||||
iam:
|
iam:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Phase-one local administrator accounts.
|
-- Phase-one local platform administrator account. Merchant accounts are created through onboarding.
|
||||||
-- Development-only initial password: ChangeMe123!
|
-- Development-only initial password: ChangeMe123!
|
||||||
-- The BCrypt hash is intentionally stored instead of plaintext.
|
-- The BCrypt hash is intentionally stored instead of plaintext.
|
||||||
USE baiyue_auth;
|
USE baiyue_auth;
|
||||||
@@ -10,9 +10,7 @@ INSERT INTO auth_account (
|
|||||||
operator_tenant_id, default_membership_id, default_app_code, deleted
|
operator_tenant_id, default_membership_id, default_app_code, deleted
|
||||||
) VALUES
|
) VALUES
|
||||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||||
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
|
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0)
|
||||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
|
||||||
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
password_hash = VALUES(password_hash),
|
password_hash = VALUES(password_hash),
|
||||||
display_name = VALUES(display_name),
|
display_name = VALUES(display_name),
|
||||||
|
|||||||
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
|
|||||||
|
|
||||||
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
|
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
|
||||||
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
|
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
|
||||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"));
|
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest {
|
|||||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
assertThat(configuration)
|
assertThat(configuration)
|
||||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}")
|
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||||
|
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DualTrackJwtConfigurationTest {
|
||||||
|
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
|
||||||
|
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
|
||||||
|
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
|
||||||
|
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
|
||||||
|
java.time.Duration.ofMinutes(15)))
|
||||||
|
.withPropertyValues(
|
||||||
|
"baiyue.security.overseas-jwt.enabled=false");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsDomesticEncoderWithoutRsaConfiguration() {
|
||||||
|
runner.run(context -> {
|
||||||
|
assertThat(context).hasNotFailed();
|
||||||
|
assertThat(context).hasBean("jwtEncoder");
|
||||||
|
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
|
||||||
|
assertThat(context).hasSingleBean(JwtEncoder.class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.KeyUse;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jose.util.Base64URL;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class JwksControllerTest {
|
||||||
|
@Test
|
||||||
|
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
|
||||||
|
RSAKey current = key("current", true);
|
||||||
|
RSAKey previous = key("previous", false);
|
||||||
|
String json = new JWKSet(List.of(current, previous)).toString(false);
|
||||||
|
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
|
||||||
|
"current", Duration.ofMinutes(15));
|
||||||
|
|
||||||
|
var body = new JwksController(properties).jwks().getBody();
|
||||||
|
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
|
||||||
|
|
||||||
|
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
|
||||||
|
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RSAKey key(String kid, boolean privatePart) {
|
||||||
|
RSAKey.Builder builder = new RSAKey.Builder(
|
||||||
|
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
|
||||||
|
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
|
||||||
|
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class OverseasSessionValidationControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
|
||||||
|
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
|
||||||
|
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
|
||||||
|
assertThat(method.getAnnotation(PostMapping.class).value())
|
||||||
|
.containsExactly("/overseas-session/validate");
|
||||||
|
assertThat(method.getAnnotation(Operation.class).description())
|
||||||
|
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
|
||||||
|
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
|
||||||
|
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||||
|
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
|
||||||
|
.doesNotContain("token", "accessToken", "authorization", "password");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-3
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class InitialAccountSeedSqlContractTest {
|
class InitialAccountSeedSqlContractTest {
|
||||||
@Test
|
@Test
|
||||||
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
|
void initializesOnlyThePlatformAdministratorAccount() throws Exception {
|
||||||
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
|
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
|
||||||
.getContentAsString(StandardCharsets.UTF_8));
|
.getContentAsString(StandardCharsets.UTF_8));
|
||||||
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
||||||
@@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest {
|
|||||||
assertThat(sql)
|
assertThat(sql)
|
||||||
.contains("(1000001, 'platform_admin'")
|
.contains("(1000001, 'platform_admin'")
|
||||||
.contains("'平台系统管理员', 'ACTIVE'")
|
.contains("'平台系统管理员', 'ACTIVE'")
|
||||||
.contains("(1000002, 'merchant_admin'")
|
|
||||||
.contains("'IT 测试客户管理员', 'ACTIVE'")
|
|
||||||
.contains("password_change_required")
|
.contains("password_change_required")
|
||||||
|
.doesNotContain("merchant_admin")
|
||||||
|
.doesNotContain("IT_TEST")
|
||||||
|
.doesNotContain("(1000002, 'merchant_admin'")
|
||||||
.doesNotContain("一期演示企业管理员");
|
.doesNotContain("一期演示企业管理员");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import java.time.Duration;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
@MockBean private JwtEncoder jwtEncoder;
|
@MockBean private JwtEncoder jwtEncoder;
|
||||||
@MockBean private AuthorizationLoader authorizationLoader;
|
@MockBean private AuthorizationLoader authorizationLoader;
|
||||||
@MockBean private TokenSessionStore tokenSessionStore;
|
@MockBean private TokenSessionStore tokenSessionStore;
|
||||||
|
@MockBean private OverseasTokenService overseasTokenService;
|
||||||
@Resource private AuthService service;
|
@Resource private AuthService service;
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
@@ -59,7 +63,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("PLATFORM");
|
account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(1);
|
account.setTokenVersion(1);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
|
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
when(repository.findByUsername("operator")).thenReturn(account);
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
@@ -67,8 +71,10 @@ class AuthServiceSmallJwtTest {
|
|||||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("small-token");
|
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||||
|
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||||
|
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
|
||||||
|
|
||||||
service.login(new LoginRequest("operator", "secret"));
|
var response = service.login(new LoginRequest("operator", "secret"));
|
||||||
|
|
||||||
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
verify(jwtEncoder).encode(jwt.capture());
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
@@ -79,15 +85,76 @@ class AuthServiceSmallJwtTest {
|
|||||||
.containsEntry("appCode", "PLATFORM")
|
.containsEntry("appCode", "PLATFORM")
|
||||||
.containsEntry("permissionVersion", 1)
|
.containsEntry("permissionVersion", 1)
|
||||||
.containsEntry("membership_id", 84L)
|
.containsEntry("membership_id", 84L)
|
||||||
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
|
verify(overseasTokenService).issue(account, snapshot);
|
||||||
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
|
||||||
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(52L);
|
||||||
|
account.setUsername("merchant");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultTenantId(10001L);
|
||||||
|
account.setOperatorTenantId(10001L);
|
||||||
|
account.setDefaultMembershipId(94L);
|
||||||
|
account.setDefaultAppCode("MERCHANT");
|
||||||
|
account.setTokenVersion(3);
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
|
||||||
|
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
|
when(tokenSessionStore.consume("refresh-old")).thenReturn(
|
||||||
|
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
|
||||||
|
when(repository.findById(52L)).thenReturn(account);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
|
||||||
|
when(overseasTokenService.issue(account, snapshot)).thenReturn(
|
||||||
|
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
|
||||||
|
|
||||||
|
var response = service.refresh("refresh-old");
|
||||||
|
|
||||||
|
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
||||||
|
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(1800);
|
||||||
|
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
|
||||||
|
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
||||||
|
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||||
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
|
verify(tokenSessionStore).consume("refresh-old");
|
||||||
|
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
|
verify(authorizationLoader).load(account);
|
||||||
|
verify(overseasTokenService).issue(account, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
|
||||||
|
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
|
||||||
|
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||||
|
.hasMessageContaining("刷新令牌无效或已过期");
|
||||||
|
|
||||||
|
verify(repository, never()).findById(any());
|
||||||
|
verify(jwtEncoder, never()).encode(any());
|
||||||
|
verify(overseasTokenService, never()).issue(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -102,7 +169,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("MERCHANT");
|
account.setDefaultAppCode("MERCHANT");
|
||||||
account.setTokenVersion(2);
|
account.setTokenVersion(2);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||||
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
||||||
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest {
|
|||||||
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||||
|
|
||||||
var result = service.register(new MerchantRegisterRequest(
|
var result = service.register(new MerchantRegisterRequest(
|
||||||
"registered_owner", "Password123", "注册人", "测试企业", "张三",
|
"registered_owner", "Password123", "注册人", "测试企业", "张三",
|
||||||
@@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest {
|
|||||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||||
|
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||||
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
|
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest {
|
|||||||
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization();
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||||
|
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class OverseasSessionValidationServiceTest {
|
||||||
|
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||||
|
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
|
||||||
|
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
|
||||||
|
private AuthAccount account;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
inject("accounts", accounts);
|
||||||
|
inject("authorizationLoader", authorizationLoader);
|
||||||
|
account = new AuthAccount();
|
||||||
|
account.setId(42L);
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setTokenVersion(8);
|
||||||
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
|
||||||
|
assertThat(service.validate(new Request(42L, 7, 900L)))
|
||||||
|
.extracting("valid", "failureReason")
|
||||||
|
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||||
|
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOrChangedWarehouseBindingFailsClosed() {
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
|
||||||
|
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
|
||||||
|
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledAccountFailsClosedBeforeIamLookup() {
|
||||||
|
account.setAccountStatus("DISABLED");
|
||||||
|
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
|
||||||
|
.isEqualTo(FailureReason.ACCOUNT_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||||
|
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void inject(String fieldName, Object value) throws Exception {
|
||||||
|
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(service, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||||
|
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
|
||||||
|
class OverseasTokenServiceTest {
|
||||||
|
@MockBean private AuthAccountRepository accounts;
|
||||||
|
@MockBean private AuthorizationLoader authorizationLoader;
|
||||||
|
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
|
||||||
|
@Resource private OverseasTokenService service;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class Config {
|
||||||
|
@Bean OverseasJwtProperties properties() {
|
||||||
|
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
|
||||||
|
Duration.ofMinutes(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signsShortRs256TokenWithCrossRegionClaims() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
|
||||||
|
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||||
|
String authority = "platform:overseas-master-data:quote:list";
|
||||||
|
String lastlegAuthority = "lastleg:pda:operate";
|
||||||
|
String domesticAuthority = "platform:firstleg:order:list";
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("overseas-token");
|
||||||
|
|
||||||
|
var response = service.exchange(42L);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder).encode(captor.capture());
|
||||||
|
var parameters = captor.getValue();
|
||||||
|
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
|
||||||
|
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||||
|
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
|
assertThat(parameters.getClaims().getClaims())
|
||||||
|
.containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
|
||||||
|
.containsEntry("membership_id", 84L)
|
||||||
|
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
||||||
|
.containsEntry("permission_version", 7L)
|
||||||
|
.containsEntry("token_version", 0)
|
||||||
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
|
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
||||||
|
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
||||||
|
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||||
|
account.setTokenVersion(8);
|
||||||
|
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||||
|
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||||
|
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(encoder.encode(any())).thenReturn(first, second);
|
||||||
|
when(first.getTokenValue()).thenReturn("overseas-old-binding");
|
||||||
|
when(second.getTokenValue()).thenReturn("overseas-new-binding");
|
||||||
|
|
||||||
|
var oldToken = service.issue(account, oldBinding);
|
||||||
|
var newToken = service.issue(account, newBinding);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder, times(2)).encode(captor.capture());
|
||||||
|
var oldClaims = captor.getAllValues().get(0).getClaims();
|
||||||
|
var newClaims = captor.getAllValues().get(1).getClaims();
|
||||||
|
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
|
||||||
|
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
|
||||||
|
.containsEntry("permission_version", 8L);
|
||||||
|
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
|
||||||
|
.containsEntry("permission_version", 9L);
|
||||||
|
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
|
||||||
|
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||||
|
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||||
|
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||||
|
|
||||||
|
service.issue(account, unbound);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder).encode(captor.capture());
|
||||||
|
assertThat(captor.getValue().getClaims().getClaims())
|
||||||
|
.doesNotContainKey("overseas_warehouse_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,13 +14,19 @@
|
|||||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||||
| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` |
|
| 头程装柜完成 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` |
|
| 契约说明 | `package-info.java` |
|
||||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.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` |
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|
||||||
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
|
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
|
||||||
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
|
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
|
||||||
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
|
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
|
||||||
|
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
|
||||||
- 从父目录运行 `mvn -pl common-event-contract -am test`。
|
- 从父目录运行 `mvn -pl common-event-contract -am test`。
|
||||||
|
|||||||
+33
-1
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
|
|||||||
if (payload == null || payload.isNull()) {
|
if (payload == null || payload.isNull()) {
|
||||||
throw new IllegalArgumentException("事件负载不能为空");
|
throw new IllegalArgumentException("事件负载不能为空");
|
||||||
}
|
}
|
||||||
|
return write(sort(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
|
||||||
|
public static byte[] canonicalizeLegacy(JsonNode payload) {
|
||||||
|
if (payload == null || payload.isNull()) {
|
||||||
|
throw new IllegalArgumentException("事件负载不能为空");
|
||||||
|
}
|
||||||
|
return write(sortLegacy(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] write(JsonNode payload) {
|
||||||
try {
|
try {
|
||||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
return OBJECT_MAPPER.writeValueAsBytes(payload);
|
||||||
} catch (JsonProcessingException exception) {
|
} catch (JsonProcessingException exception) {
|
||||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||||
}
|
}
|
||||||
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
|
|||||||
node.forEach(child -> result.add(sort(child)));
|
node.forEach(child -> result.add(sort(child)));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if (node.isNumber()) {
|
||||||
|
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode sortLegacy(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
var names = new java.util.ArrayList<String>();
|
||||||
|
node.fieldNames().forEachRemaining(names::add);
|
||||||
|
names.sort(String::compareTo);
|
||||||
|
var result = OBJECT_MAPPER.createObjectNode();
|
||||||
|
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
var result = OBJECT_MAPPER.createArrayNode();
|
||||||
|
node.forEach(child -> result.add(sortLegacy(child)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public final class EventTypes {
|
|||||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||||
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||||
|
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
||||||
|
"FirstLegContainerShippingNodeRecorded";
|
||||||
|
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||||
|
public record FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
String queryId,
|
||||||
|
String customerId,
|
||||||
|
String oceanBookingId,
|
||||||
|
String containerBookingId,
|
||||||
|
String containerNo,
|
||||||
|
String standardNode,
|
||||||
|
int nodeSequence,
|
||||||
|
Instant observedAt,
|
||||||
|
Instant actualAt,
|
||||||
|
String locationCode,
|
||||||
|
String locationName,
|
||||||
|
String providerCode,
|
||||||
|
String providerEventId,
|
||||||
|
String providerNodeCode,
|
||||||
|
String sourceReference,
|
||||||
|
String rawPayloadHash) {
|
||||||
|
private static final Map<String, Integer> STANDARD_NODES = Map.of(
|
||||||
|
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
|
||||||
|
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
|
||||||
|
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
|
||||||
|
"EMPTY_RETURNED", 100);
|
||||||
|
|
||||||
|
public FirstLegContainerShippingNodeRecordedV1 {
|
||||||
|
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
||||||
|
|| oceanBookingId == null || oceanBookingId.isBlank()
|
||||||
|
|| containerBookingId == null || containerBookingId.isBlank()
|
||||||
|
|| containerNo == null || containerNo.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||||
|
}
|
||||||
|
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||||
|
|| observedAt == null || actualAt == null) {
|
||||||
|
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||||
|
}
|
||||||
|
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|
||||||
|
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|
||||||
|
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
|
||||||
|
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-2
@@ -16,7 +16,37 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items) {
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||||
|
String destinationType) {
|
||||||
|
public Order {
|
||||||
|
items = items == null ? List.of() : items;
|
||||||
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
|
}
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||||
|
}
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Item(
|
public record Item(
|
||||||
@@ -27,6 +57,31 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
String destinationWarehouseId, String destinationWarehouseCode,
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
String destinationWarehouseName, String fbaTrackingNo,
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
String shipmentNo, String shippingMark) {
|
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
|
||||||
|
public Item {
|
||||||
|
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||||
|
}
|
||||||
|
public Item(String sourceInboundDetailId, String skuCode,
|
||||||
|
String productNameZh, String productNameEn,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||||
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
|
String shipmentNo, String shippingMark) {
|
||||||
|
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||||
|
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||||
|
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||||
|
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||||
|
shipmentNo, shippingMark, null, List.of());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一键代发入仓单的托盘冻结快照。 */
|
||||||
|
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||||
|
|
||||||
|
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
|
||||||
|
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||||
|
int packageCount, int productQuantity,
|
||||||
|
List<String> cartonMarks) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-15
@@ -3,23 +3,94 @@ package com.baiyue.common.event;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 装柜完成交接快照,包含三类逐货物备注。 */
|
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
||||||
public record FirstLegLoadingPlanCompletedV2(
|
public record FirstLegLoadingPlanCompletedV2(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
List<Order> orders) {
|
List<Order> orders) {
|
||||||
public record Order(String sourceInboundId, String rootInboundId, String sourceOrderId,
|
public record Order(
|
||||||
String sourceOrderNo, String rootOrderId, String inboundCode,
|
String sourceInboundId, String rootInboundId,
|
||||||
String destinationWarehouseId, String destinationWarehouseCode,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
String destinationWarehouseName, int packageCount, Integer productQuantity,
|
String inboundCode, String destinationWarehouseId,
|
||||||
Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
BigDecimal volumeCbm, List<Item> items) {}
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
public record Item(String sourceInboundDetailId, String skuCode, String productNameZh,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
String productNameEn, int packageCount, Integer productQuantity,
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||||
Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
String destinationType) {
|
||||||
String destinationWarehouseId, String destinationWarehouseCode,
|
public Order {
|
||||||
String destinationWarehouseName, String fbaTrackingNo, String shipmentNo,
|
items = items == null ? List.of() : items;
|
||||||
String shippingMark, String customerRemark, String internalRemark,
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
String warehouseRemark) {}
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Item(
|
||||||
|
String sourceInboundDetailId, String skuCode,
|
||||||
|
String productNameZh, String productNameEn,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||||
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
|
String shipmentNo, String shippingMark,
|
||||||
|
String customerRemark, String internalRemark, String warehouseRemark,
|
||||||
|
String palletNo,
|
||||||
|
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
|
||||||
|
List<String> cartonMarks) {
|
||||||
|
public Item {
|
||||||
|
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Item(String sourceInboundDetailId, String skuCode,
|
||||||
|
String productNameZh, String productNameEn,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||||
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
|
String shipmentNo, String shippingMark,
|
||||||
|
String customerRemark, String internalRemark, String warehouseRemark) {
|
||||||
|
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||||
|
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||||
|
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||||
|
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||||
|
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
|
||||||
|
null, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键代发入仓单的托盘冻结快照。 */
|
||||||
|
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||||
|
|
||||||
|
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
||||||
|
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||||
|
int packageCount, int productQuantity,
|
||||||
|
List<String> cartonMarks) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
||||||
|
public final class IamSecurityEventsV1 {
|
||||||
|
public static final int VERSION = 1;
|
||||||
|
|
||||||
|
private IamSecurityEventsV1() { }
|
||||||
|
|
||||||
|
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
|
||||||
|
String status, long permissionVersion, Instant changedAt) { }
|
||||||
|
|
||||||
|
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
||||||
|
|
||||||
|
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
||||||
|
long permissionVersion, String reason, Instant changedAt) { }
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。
|
||||||
|
* unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。
|
||||||
|
*/
|
||||||
|
public record OverseasContainerUnloadedV1(
|
||||||
|
String sourceContainerBookingId,
|
||||||
|
String containerNo,
|
||||||
|
Instant unloadingCompletedAt,
|
||||||
|
Instant unloadingPhotoUploadedAt,
|
||||||
|
Instant actualEmptyReportedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
|
||||||
|
class EventPayloadCanonicalizerTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
|
||||||
|
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
|
||||||
|
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
|
||||||
|
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
|
||||||
|
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
|
||||||
|
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
|
||||||
|
var negativeZero = json.readTree("{\"value\":-0.000}");
|
||||||
|
var zero = json.readTree("{\"value\":0}");
|
||||||
|
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
|
||||||
|
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
|
||||||
|
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class FirstLegContainerShippingNodeRecordedV1Test {
|
||||||
|
@Test
|
||||||
|
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
|
||||||
|
var payload = payload();
|
||||||
|
var mapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
var restored = mapper.readValue(mapper.writeValueAsString(payload),
|
||||||
|
FirstLegContainerShippingNodeRecordedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored).isEqualTo(payload);
|
||||||
|
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||||
|
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||||
|
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||||
|
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||||
|
return new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"2088000000000000001", "2088000000000000002", "2088000000000000004",
|
||||||
|
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
||||||
|
"ZIM:evt-1", "a".repeat(64));
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -26,4 +26,38 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
||||||
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
|
||||||
|
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||||
|
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||||
|
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||||
|
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||||
|
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||||
|
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||||
|
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||||
|
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||||
|
"3", "海外仓", List.of(order));
|
||||||
|
|
||||||
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored).isEqualTo(payload);
|
||||||
|
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||||
|
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
||||||
|
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -21,4 +21,49 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||||
.isEqualTo("仓库备注");
|
.isEqualTo("仓库备注");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
|
||||||
|
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
|
||||||
|
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||||
|
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
|
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
|
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||||
|
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||||
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void carriesFrozenDestinationType() {
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||||
|
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
|
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsEachOrdinaryCartonNumberToItsSku() {
|
||||||
|
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
|
||||||
|
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||||
|
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
|
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||||
|
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class OverseasContainerUnloadedV1Test {
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||||
|
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||||
|
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||||
|
|
||||||
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
|
||||||
|
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||||
|
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||||
|
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||||
|
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
|
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
|
||||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||||
| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
|
| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
|
||||||
|
| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` |
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|
||||||
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
|
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
|
||||||
|
- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。
|
||||||
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
|
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
|
||||||
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
|
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
|
||||||
- 从父目录运行 `mvn -pl common-openapi -am test`。
|
- 从父目录运行 `mvn -pl common-openapi -am test`。
|
||||||
|
|||||||
+8
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
|
|||||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||||
|
return GroupedOpenApi.builder().group("pda")
|
||||||
|
.displayName("PDA APIs / PDA 作业接口")
|
||||||
|
.pathsToMatch("/api/pda/**")
|
||||||
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
|
}
|
||||||
|
|
||||||
private Parameter header(String name, String description, boolean required) {
|
private Parameter header(String name, String description, boolean required) {
|
||||||
return new HeaderParameter().name(name).description(description).required(required)
|
return new HeaderParameter().name(name).description(description).required(required)
|
||||||
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.common.openapi;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PdaOpenApiGroupContractTest {
|
||||||
|
@Test
|
||||||
|
void pdaGroupOnlyMatchesDeviceApis() {
|
||||||
|
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
|
||||||
|
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
|
||||||
|
|
||||||
|
assertThat(group.getGroup()).isEqualTo("pda");
|
||||||
|
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
|
||||||
|
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
|
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
|
||||||
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
|
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
|
||||||
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
|
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
|
||||||
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
|
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
|
||||||
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
|
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
|
||||||
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
|
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
|
||||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
|
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
|
||||||
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
|
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
|
||||||
|
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
|
||||||
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
|
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
|
||||||
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
|
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
|
||||||
- 从父目录运行 `mvn -pl common-security -am test`。
|
- 从父目录运行 `mvn -pl common-security -am test`。
|
||||||
|
|||||||
@@ -9,11 +9,34 @@ import java.util.Set;
|
|||||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
String username, String displayName, String appCode, String clientType,
|
String username, String displayName, String appCode, String clientType,
|
||||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||||
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
|
Long actorMembershipId, boolean passwordChangeRequired,
|
||||||
|
Long overseasWarehouseId) implements Principal {
|
||||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
String username, String appCode, String clientType, Set<String> permissions) {
|
String username, String appCode, String clientType, Set<String> permissions) {
|
||||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||||
permissions, false, null, null, false);
|
permissions, false, null, null, false, null);
|
||||||
|
}
|
||||||
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
|
String username, String appCode, String clientType, Set<String> permissions,
|
||||||
|
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||||
|
boolean passwordChangeRequired) {
|
||||||
|
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||||
|
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||||
|
}
|
||||||
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
|
String username, String displayName, String appCode, String clientType,
|
||||||
|
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||||
|
Long actorMembershipId, boolean passwordChangeRequired) {
|
||||||
|
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
|
||||||
|
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||||
|
}
|
||||||
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
|
String username, String appCode, String clientType, Set<String> permissions,
|
||||||
|
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||||
|
boolean passwordChangeRequired, Long overseasWarehouseId) {
|
||||||
|
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||||
|
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
|
||||||
|
overseasWarehouseId);
|
||||||
}
|
}
|
||||||
public AuthenticatedUser {
|
public AuthenticatedUser {
|
||||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public class JwtClaimsMapper {
|
|||||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||||
longClaim(jwt, "actor_user_id"),
|
longClaim(jwt, "actor_user_id"),
|
||||||
longClaim(jwt, "actor_membership_id"),
|
longClaim(jwt, "actor_membership_id"),
|
||||||
Boolean.TRUE.equals(jwt.getClaim("password_change_required")));
|
Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
|
||||||
|
longClaim(jwt, "overseas_warehouse_id"));
|
||||||
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
||||||
.map(SimpleGrantedAuthority::new).toList();
|
.map(SimpleGrantedAuthority::new).toList();
|
||||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
|
|||||||
.claim("actor_user_id", 42L)
|
.claim("actor_user_id", 42L)
|
||||||
.claim("actor_membership_id", 43L)
|
.claim("actor_membership_id", 43L)
|
||||||
.claim("password_change_required", true)
|
.claim("password_change_required", true)
|
||||||
|
.claim("overseas_warehouse_id", 900L)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
||||||
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
|
|||||||
assertThat(user.actorUserId()).isEqualTo(42L);
|
assertThat(user.actorUserId()).isEqualTo(42L);
|
||||||
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
||||||
assertThat(user.passwordChangeRequired()).isTrue();
|
assertThat(user.passwordChangeRequired()).isTrue();
|
||||||
|
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
|
||||||
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
|
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
|
||||||
|
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
|
||||||
|
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
|
||||||
|
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
|
||||||
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
||||||
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
||||||
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
|
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
|
||||||
@@ -23,5 +26,8 @@
|
|||||||
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||||
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
|
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
|
||||||
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
|
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
|
||||||
|
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
|
||||||
|
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
|
||||||
|
- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。
|
||||||
- 从父目录运行 `mvn -pl common-web -am test`。
|
- 从父目录运行 `mvn -pl common-web -am test`。
|
||||||
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
<groupId>io.micrometer</groupId>
|
<groupId>io.micrometer</groupId>
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openfeign</groupId>
|
||||||
|
<artifactId>feign-core</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
|
|||||||
public final class CorrelationContext {
|
public final class CorrelationContext {
|
||||||
public static final String REQUEST_ID = "requestId";
|
public static final String REQUEST_ID = "requestId";
|
||||||
public static final String TRACE_ID = "traceId";
|
public static final String TRACE_ID = "traceId";
|
||||||
|
public static final String SPAN_ID = "spanId";
|
||||||
|
public static final String TRACE_PARENT = "traceparent";
|
||||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||||
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
||||||
|
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
|
||||||
|
|
||||||
private CorrelationContext() {}
|
private CorrelationContext() {}
|
||||||
|
|
||||||
@@ -22,6 +25,14 @@ public final class CorrelationContext {
|
|||||||
return value(request, TRACE_ID);
|
return value(request, TRACE_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String spanId(HttpServletRequest request) {
|
||||||
|
return value(request, SPAN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String traceParent(HttpServletRequest request) {
|
||||||
|
return value(request, TRACE_PARENT);
|
||||||
|
}
|
||||||
|
|
||||||
private static String value(HttpServletRequest request, String key) {
|
private static String value(HttpServletRequest request, String key) {
|
||||||
Object attribute = request.getAttribute(key);
|
Object attribute = request.getAttribute(key);
|
||||||
return attribute == null ? MDC.get(key) : attribute.toString();
|
return attribute == null ? MDC.get(key) : attribute.toString();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Enumeration;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
|
|||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false);
|
String requestId = requestId(request);
|
||||||
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true);
|
TraceParent traceParent = traceParent(request);
|
||||||
|
String traceId = traceParent.traceId();
|
||||||
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
|
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
|
||||||
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
|
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
|
||||||
|
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
|
||||||
|
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
|
||||||
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
||||||
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
|
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
|
||||||
|
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
|
||||||
|
|
||||||
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
|
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
|
||||||
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) {
|
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
|
||||||
|
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String acceptedOrGenerated(String candidate, boolean compact) {
|
private String requestId(HttpServletRequest request) {
|
||||||
if (candidate != null && SAFE_ID.matcher(candidate).matches()) {
|
HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
|
||||||
return candidate;
|
if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
|
||||||
|
return header.value();
|
||||||
|
}
|
||||||
|
return UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TraceParent traceParent(HttpServletRequest request) {
|
||||||
|
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
TraceParent.HEADER_LENGTH);
|
||||||
|
if (standard.present()) {
|
||||||
|
if (standard.valid()) {
|
||||||
|
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
|
||||||
|
.orElseGet(() -> TraceParent.newRoot(null));
|
||||||
|
}
|
||||||
|
return TraceParent.newRoot(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
|
||||||
|
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
|
||||||
|
Enumeration<String> values = request.getHeaders(name);
|
||||||
|
if (values == null || !values.hasMoreElements()) {
|
||||||
|
return HeaderValue.missing();
|
||||||
|
}
|
||||||
|
String value = values.nextElement();
|
||||||
|
boolean duplicate = values.hasMoreElements();
|
||||||
|
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record HeaderValue(boolean present, boolean valid, String value) {
|
||||||
|
private static HeaderValue missing() {
|
||||||
|
return new HeaderValue(false, false, null);
|
||||||
}
|
}
|
||||||
String id = UUID.randomUUID().toString();
|
|
||||||
return compact ? id.replace("-", "") : id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
||||||
|
/** Enables synchronous Feign trace propagation without making Feign a required dependency. */
|
||||||
|
@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration")
|
||||||
|
@ConditionalOnClass(name = "feign.RequestInterceptor")
|
||||||
|
public class FeignTracePropagationAutoConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(FeignTraceRequestInterceptor.class)
|
||||||
|
FeignTraceRequestInterceptor feignTraceRequestInterceptor() {
|
||||||
|
return new FeignTraceRequestInterceptor();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import feign.RequestInterceptor;
|
||||||
|
import feign.RequestTemplate;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Propagates the trusted synchronous request trace context to every Feign invocation. */
|
||||||
|
public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered {
|
||||||
|
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void apply(RequestTemplate template) {
|
||||||
|
removeManagedHeaders(template);
|
||||||
|
|
||||||
|
String requestId = MDC.get(CorrelationContext.REQUEST_ID);
|
||||||
|
if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) {
|
||||||
|
template.header(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String traceId = MDC.get(CorrelationContext.TRACE_ID);
|
||||||
|
if (TraceParent.validLegacyTraceId(traceId)) {
|
||||||
|
TraceParent outbound = TraceParent.outboundChild(traceId);
|
||||||
|
template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
|
||||||
|
template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return Ordered.LOWEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeManagedHeaders(RequestTemplate template) {
|
||||||
|
template.removeHeader(CorrelationContext.REQUEST_ID_HEADER);
|
||||||
|
template.removeHeader(CorrelationContext.TRACE_ID_HEADER);
|
||||||
|
template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
|
|||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -79,6 +81,21 @@ public class GlobalExceptionHandler {
|
|||||||
return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request);
|
return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(
|
||||||
|
ResponseStatusException exception, HttpServletRequest request) {
|
||||||
|
ResultCode code = resultCode(exception.getStatusCode().value());
|
||||||
|
String message = exception.getReason() == null || exception.getReason().isBlank()
|
||||||
|
? code.message() : exception.getReason();
|
||||||
|
return failure(code, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
|
||||||
|
NoResourceFoundException exception, HttpServletRequest request) {
|
||||||
|
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||||
log.error("Unhandled request failure requestId={} traceId={}",
|
log.error("Unhandled request failure requestId={} traceId={}",
|
||||||
@@ -123,4 +140,18 @@ public class GlobalExceptionHandler {
|
|||||||
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
|
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ResultCode resultCode(int status) {
|
||||||
|
return switch (status) {
|
||||||
|
case 400 -> ResultCode.BAD_REQUEST;
|
||||||
|
case 401 -> ResultCode.UNAUTHORIZED;
|
||||||
|
case 403 -> ResultCode.FORBIDDEN;
|
||||||
|
case 404 -> ResultCode.NOT_FOUND;
|
||||||
|
case 409 -> ResultCode.CONFLICT;
|
||||||
|
case 422 -> ResultCode.VALIDATION_FAILED;
|
||||||
|
case 429 -> ResultCode.TOO_MANY_REQUESTS;
|
||||||
|
case 503 -> ResultCode.SERVICE_UNAVAILABLE;
|
||||||
|
default -> ResultCode.INTERNAL_ERROR;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Immutable standard headers for one synchronous outbound child span. */
|
||||||
|
public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) {
|
||||||
|
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||||
|
|
||||||
|
/** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */
|
||||||
|
public static OutboundTraceHeaders fromCurrentOrNew() {
|
||||||
|
String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID));
|
||||||
|
String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID))
|
||||||
|
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||||
|
return child(requestId, traceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */
|
||||||
|
public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) {
|
||||||
|
String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId)
|
||||||
|
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||||
|
return child(UUID.randomUUID().toString(), traceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String spanId() {
|
||||||
|
return TraceParent.parse(traceParent).orElseThrow().parentId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OutboundTraceHeaders child(String requestId, String traceId) {
|
||||||
|
TraceParent child = TraceParent.outboundChild(traceId);
|
||||||
|
return new OutboundTraceHeaders(requestId, child.traceId(), child.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeRequestId(String value) {
|
||||||
|
return value != null && SAFE_REQUEST_ID.matcher(value).matches()
|
||||||
|
? value : UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
/** Installs the trace interceptor after ordinary RestClient customizers. */
|
||||||
|
public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered {
|
||||||
|
private final RestClientTraceInterceptor interceptor;
|
||||||
|
|
||||||
|
public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) {
|
||||||
|
this.interceptor = interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void customize(RestClient.Builder builder) {
|
||||||
|
builder.requestInterceptor(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return Ordered.LOWEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpRequest;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Applies the trusted synchronous trace context to Spring RestClient requests. */
|
||||||
|
public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor {
|
||||||
|
@Override
|
||||||
|
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||||
|
ClientHttpRequestExecution execution) throws IOException {
|
||||||
|
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
|
||||||
|
request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId());
|
||||||
|
request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
|
||||||
|
request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
|
||||||
|
return execution.execute(request, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
|
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
/** Adds trace propagation to Spring-managed RestClient builders. */
|
||||||
|
@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration")
|
||||||
|
@ConditionalOnClass({RestClient.class, RestClientCustomizer.class})
|
||||||
|
public class RestClientTracePropagationAutoConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnMissingBean(RestClientTraceInterceptor.class)
|
||||||
|
RestClientTraceInterceptor restClientTraceInterceptor() {
|
||||||
|
return new RestClientTraceInterceptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) {
|
||||||
|
return new RestClientTraceCustomizer(interceptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict W3C {@code traceparent} version 00 value.
|
||||||
|
*
|
||||||
|
* <p>This type deliberately accepts only the currently frozen version-00 shape. Unsupported,
|
||||||
|
* ambiguous or malformed input must start a new trace instead of being partially trusted.</p>
|
||||||
|
*/
|
||||||
|
public record TraceParent(String traceId, String parentId, String traceFlags) {
|
||||||
|
public static final String HEADER = "traceparent";
|
||||||
|
public static final int HEADER_LENGTH = 55;
|
||||||
|
|
||||||
|
private static final Pattern VERSION_00 = Pattern.compile(
|
||||||
|
"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})");
|
||||||
|
private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}");
|
||||||
|
private static final String ZERO_TRACE_ID = "0".repeat(32);
|
||||||
|
private static final String ZERO_PARENT_ID = "0".repeat(16);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final HexFormat HEX = HexFormat.of();
|
||||||
|
|
||||||
|
public TraceParent {
|
||||||
|
if (!validTraceId(traceId) || !validParentId(parentId)
|
||||||
|
|| traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) {
|
||||||
|
throw new IllegalArgumentException("Invalid W3C traceparent components");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses one exact, lowercase W3C version-00 header value. */
|
||||||
|
public static Optional<TraceParent> parse(String value) {
|
||||||
|
if (value == null || value.length() != HEADER_LENGTH) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Matcher matcher = VERSION_00.matcher(value);
|
||||||
|
if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the local inbound context for a valid remote parent. */
|
||||||
|
public static TraceParent childOf(TraceParent remoteParent) {
|
||||||
|
return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new root context, optionally reusing a valid legacy trace ID. */
|
||||||
|
public static TraceParent newRoot(String legacyTraceId) {
|
||||||
|
String traceId = validLegacyTraceId(legacyTraceId)
|
||||||
|
? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16);
|
||||||
|
return new TraceParent(traceId, randomNonZero(8), "00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a sampled outbound child for a trusted current trace ID. */
|
||||||
|
public static TraceParent outboundChild(String currentTraceId) {
|
||||||
|
if (!validLegacyTraceId(currentTraceId)) {
|
||||||
|
throw new IllegalArgumentException("Current trace ID is invalid");
|
||||||
|
}
|
||||||
|
return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a trace ID from a trusted persisted/internal context.
|
||||||
|
*
|
||||||
|
* <p>Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because
|
||||||
|
* existing event envelopes historically persisted UUID trace IDs.</p>
|
||||||
|
*/
|
||||||
|
public static Optional<String> normalizeTrustedTraceId(String value) {
|
||||||
|
if (validLegacyTraceId(value)) {
|
||||||
|
return Optional.of(value.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
if (value != null && value.matches(
|
||||||
|
"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) {
|
||||||
|
String compact = value.replace("-", "").toLowerCase(Locale.ROOT);
|
||||||
|
return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean validLegacyTraceId(String value) {
|
||||||
|
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
|
||||||
|
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String value() {
|
||||||
|
return "00-" + traceId + '-' + parentId + '-' + traceFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validTraceId(String value) {
|
||||||
|
return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validParentId(String value) {
|
||||||
|
return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String randomNonZero(int byteCount) {
|
||||||
|
byte[] bytes = new byte[byteCount];
|
||||||
|
String value;
|
||||||
|
do {
|
||||||
|
RANDOM.nextBytes(bytes);
|
||||||
|
value = HEX.formatHex(bytes);
|
||||||
|
} while (value.chars().allMatch(character -> character == '0'));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -1,2 +1,4 @@
|
|||||||
com.baiyue.common.web.LongIdJacksonConfiguration
|
com.baiyue.common.web.LongIdJacksonConfiguration
|
||||||
com.baiyue.common.web.CommonWebAutoConfiguration
|
com.baiyue.common.web.CommonWebAutoConfiguration
|
||||||
|
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
|
||||||
|
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.baiyue.common.web;
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest {
|
|||||||
contextRunner.run(context -> {
|
contextRunner.run(context -> {
|
||||||
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
|
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
|
||||||
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
|
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
|
||||||
|
assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value())
|
||||||
|
.isEqualTo(Ordered.HIGHEST_PRECEDENCE);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain;
|
|||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@DisplayName("请求链路标识过滤功能")
|
@DisplayName("请求链路标识过滤功能")
|
||||||
@@ -13,27 +15,87 @@ class CorrelationIdFilterTest {
|
|||||||
private final CorrelationIdFilter filter = new CorrelationIdFilter();
|
private final CorrelationIdFilter filter = new CorrelationIdFilter();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void preservesSafeIncomingIds() throws Exception {
|
void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
|
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
|
||||||
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123");
|
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
filter.doFilter(request, response, new MockFilterChain());
|
CapturingFilterChain chain = new CapturingFilterChain();
|
||||||
|
filter.doFilter(request, response, chain);
|
||||||
|
|
||||||
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
|
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
|
||||||
assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123");
|
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||||
|
assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
|
||||||
|
assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull();
|
||||||
|
assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replacesUnsafeIncomingIds() throws Exception {
|
void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n");
|
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
filter.doFilter(request, response, new MockFilterChain());
|
filter.doFilter(request, response, new MockFilterChain());
|
||||||
|
|
||||||
|
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.isEqualTo("abcdef0123456789abcdef0123456789");
|
||||||
|
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-00000000000000000000000000000000-00f067aa0ba902b7-01");
|
||||||
|
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
filter.doFilter(request, response, new MockFilterChain());
|
||||||
|
|
||||||
|
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.matches("[0-9a-f]{32}")
|
||||||
|
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
.isNotEqualTo("00000000000000000000000000000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateAndOversizedHeaders() throws Exception {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129));
|
||||||
|
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
||||||
|
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
filter.doFilter(request, response, new MockFilterChain());
|
||||||
|
|
||||||
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
|
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
|
||||||
.matches("[0-9a-f-]{36}");
|
.matches("[0-9a-f-]{36}");
|
||||||
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||||
.matches("[0-9a-f]{32}");
|
.matches("[0-9a-f]{32}")
|
||||||
|
.isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736")
|
||||||
|
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CapturingFilterChain extends MockFilterChain {
|
||||||
|
private String traceId;
|
||||||
|
private String spanId;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||||
|
jakarta.servlet.ServletResponse response) throws IOException {
|
||||||
|
traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID);
|
||||||
|
spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import feign.RequestInterceptor;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.test.context.FilteredClassLoader;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FeignTracePropagationAutoConfigurationTest {
|
||||||
|
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registersTraceInterceptorWhenFeignIsPresent() {
|
||||||
|
contextRunner.run(context -> {
|
||||||
|
assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class);
|
||||||
|
assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotLoadFeignConfigurationWhenFeignIsAbsent() {
|
||||||
|
contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class))
|
||||||
|
.run(context -> {
|
||||||
|
assertThat(context).hasNotFailed();
|
||||||
|
assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backsOffOnlyForAnApplicationProvidedTraceInterceptor() {
|
||||||
|
contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new)
|
||||||
|
.run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import feign.RequestTemplate;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FeignTraceRequestInterceptorTest {
|
||||||
|
private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearMdc() {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runsLastSoTrustedContextHasFinalHeaderAuthority() {
|
||||||
|
assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() {
|
||||||
|
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123");
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
RequestTemplate template = templateWithSpoofedHeaders();
|
||||||
|
|
||||||
|
interceptor.apply(template);
|
||||||
|
|
||||||
|
assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER))
|
||||||
|
.containsExactly("req-trusted-123");
|
||||||
|
assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.singleElement().asString()
|
||||||
|
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsDifferentNonZeroSpanForEachSynchronousInvocation() {
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
RequestTemplate first = new RequestTemplate();
|
||||||
|
RequestTemplate second = new RequestTemplate();
|
||||||
|
|
||||||
|
interceptor.apply(first);
|
||||||
|
interceptor.apply(second);
|
||||||
|
|
||||||
|
assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() {
|
||||||
|
MDC.put(CorrelationContext.REQUEST_ID, "invalid request id");
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id");
|
||||||
|
RequestTemplate template = templateWithSpoofedHeaders();
|
||||||
|
|
||||||
|
interceptor.apply(template);
|
||||||
|
|
||||||
|
assertThat(template.headers()).doesNotContainKeys(
|
||||||
|
CorrelationContext.REQUEST_ID_HEADER,
|
||||||
|
CorrelationContext.TRACE_ID_HEADER,
|
||||||
|
CorrelationContext.TRACE_PARENT_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RequestTemplate templateWithSpoofedHeaders() {
|
||||||
|
RequestTemplate template = new RequestTemplate();
|
||||||
|
template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter");
|
||||||
|
template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
template.header(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String singleHeader(RequestTemplate template, String name) {
|
||||||
|
return template.headers().get(name).iterator().next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,10 @@ import org.springframework.validation.BindException;
|
|||||||
import org.springframework.validation.FieldError;
|
import org.springframework.validation.FieldError;
|
||||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
@@ -87,6 +90,32 @@ class GlobalExceptionHandlerTest {
|
|||||||
.tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0);
|
.tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void responseStatusConflictMapsToCanonicalConflictInsteadOfInternalError() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "stale-version");
|
||||||
|
|
||||||
|
var response = handler.handleResponseStatus(
|
||||||
|
new ResponseStatusException(HttpStatus.CONFLICT, "Order was changed; refresh and retry"), request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("Order was changed; refresh and retry");
|
||||||
|
assertThat(response.getBody().requestId()).isEqualTo("stale-version");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingControllerPathMapsToNotFoundInsteadOfInternalError() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|
||||||
|
var response = handler.handleNoResourceFound(
|
||||||
|
new NoResourceFoundException(org.springframework.http.HttpMethod.GET,
|
||||||
|
"api/platform/v1/last-leg/dispatches"), request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
||||||
|
}
|
||||||
|
|
||||||
private void paging(long page) {
|
private void paging(long page) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class OutboundTraceHeadersTest {
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearMdc() {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void currentContextCreatesAValidChildWithoutUsingBusinessValues() {
|
||||||
|
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted");
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
|
||||||
|
OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew();
|
||||||
|
|
||||||
|
assertThat(headers.requestId()).isEqualTo("req-trusted");
|
||||||
|
assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assertThat(headers.traceParent())
|
||||||
|
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() {
|
||||||
|
OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||||
|
"550e8400-e29b-41d4-a716-446655440000");
|
||||||
|
OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||||
|
"ORDER:customer-99:booking-123");
|
||||||
|
|
||||||
|
assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000");
|
||||||
|
assertThat(invalid.traceId()).matches("[0-9a-f]{32}")
|
||||||
|
.isNotEqualTo("ORDER:customer-99:booking-123");
|
||||||
|
assertThat(invalid.requestId()).matches("[0-9a-f-]{36}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.slf4j.MDC;
|
||||||
|
import org.springframework.mock.http.client.MockClientHttpRequest;
|
||||||
|
import org.springframework.mock.http.client.MockClientHttpResponse;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RestClientTraceInterceptorTest {
|
||||||
|
private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearMdc() {
|
||||||
|
MDC.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception {
|
||||||
|
MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client");
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||||
|
request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id");
|
||||||
|
request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||||
|
request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER,
|
||||||
|
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||||
|
AtomicReference<org.springframework.http.HttpHeaders> captured = new AtomicReference<>();
|
||||||
|
|
||||||
|
interceptor.intercept(request, new byte[0], (outbound, body) -> {
|
||||||
|
captured.set(outbound.getHeaders());
|
||||||
|
return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK);
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER))
|
||||||
|
.containsExactly("req-rest-client");
|
||||||
|
assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception {
|
||||||
|
MDC.put(CorrelationContext.TRACE_ID, "business-order-123");
|
||||||
|
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||||
|
|
||||||
|
interceptor.intercept(request, new byte[0], (outbound, body) ->
|
||||||
|
new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK));
|
||||||
|
|
||||||
|
assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER))
|
||||||
|
.matches("[0-9a-f-]{36}");
|
||||||
|
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER))
|
||||||
|
.matches("[0-9a-f]{32}").isNotEqualTo("business-order-123");
|
||||||
|
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||||
|
.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class RestClientTracePropagationAutoConfigurationTest {
|
||||||
|
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class));
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registersInterceptorAndIndependentCustomizer() {
|
||||||
|
contextRunner.run(context -> {
|
||||||
|
assertThat(context).hasSingleBean(RestClientTraceInterceptor.class);
|
||||||
|
assertThat(context).hasBean("baiyueTraceRestClientCustomizer");
|
||||||
|
assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1);
|
||||||
|
RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class);
|
||||||
|
assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
|
||||||
|
RestClient.Builder builder = mock(RestClient.Builder.class);
|
||||||
|
customizer.customize(builder);
|
||||||
|
verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reusesApplicationProvidedTraceInterceptor() {
|
||||||
|
contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new)
|
||||||
|
.run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.baiyue.common.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TraceParentTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parsesCanonicalVersion00AndCreatesLocalChild() {
|
||||||
|
TraceParent remote = TraceParent.parse(
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow();
|
||||||
|
|
||||||
|
TraceParent local = TraceParent.childOf(remote);
|
||||||
|
|
||||||
|
assertThat(local.traceId()).isEqualTo(remote.traceId());
|
||||||
|
assertThat(local.parentId()).matches("[0-9a-f]{16}")
|
||||||
|
.isNotEqualTo(remote.parentId())
|
||||||
|
.isNotEqualTo("0000000000000000");
|
||||||
|
assertThat(local.traceFlags()).isEqualTo("01");
|
||||||
|
assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() {
|
||||||
|
assertThat(TraceParent.parse(
|
||||||
|
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty();
|
||||||
|
assertThat(TraceParent.parse(
|
||||||
|
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty();
|
||||||
|
assertThat(TraceParent.parse(
|
||||||
|
"00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty();
|
||||||
|
assertThat(TraceParent.parse(
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty();
|
||||||
|
assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsCanonicalNonZeroRootAndNormalizesLegacyHex() {
|
||||||
|
TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789");
|
||||||
|
|
||||||
|
assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789");
|
||||||
|
assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
|
||||||
|
assertThat(root.value()).matches(
|
||||||
|
"00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsSampledOutboundChildForTrustedCurrentTrace() {
|
||||||
|
TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736");
|
||||||
|
|
||||||
|
assertThat(child.value()).matches(
|
||||||
|
"00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() {
|
||||||
|
assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000"))
|
||||||
|
.contains("550e8400e29b41d4a716446655440000");
|
||||||
|
assertThat(TraceParent.parse(
|
||||||
|
"00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,12 @@ spring:
|
|||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue:
|
baiyue:
|
||||||
security:
|
security:
|
||||||
audience: ${JWT_AUDIENCE:lastleg-api}
|
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
|
||||||
access-token-ttl: ${ACCESS_TOKEN_TTL:15m}
|
|
||||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||||
|
overseas-jwt:
|
||||||
|
enabled: ${OVERSEAS_JWT_ENABLED:true}
|
||||||
|
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||||
|
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||||
|
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||||
|
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||||
|
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ baiyue:
|
|||||||
region: ${REGION_CODE:CN}
|
region: ${REGION_CODE:CN}
|
||||||
deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
|
deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
|
||||||
data-residency: ${DATA_RESIDENCY:CN}
|
data-residency: ${DATA_RESIDENCY:CN}
|
||||||
|
event-dispatcher:
|
||||||
|
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ spring:
|
|||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
|
baiyue:
|
||||||
|
carrier-schedule:
|
||||||
|
providers:
|
||||||
|
matson-automated: ${MATSON_TRACKING_ENABLED:false}
|
||||||
|
sml-automated: ${SML_TRACKING_ENABLED:false}
|
||||||
|
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
|
||||||
|
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
|
||||||
|
emc-automated: ${EMC_TRACKING_ENABLED:false}
|
||||||
|
hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
||||||
|
scheduler:
|
||||||
|
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
||||||
|
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
||||||
|
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
||||||
|
batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
|
||||||
|
max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
|
||||||
|
lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
|
||||||
|
lastleg:
|
||||||
|
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}
|
||||||
|
outbox-relay:
|
||||||
|
fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||||
|
lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||||
|
retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||||
firstleg:
|
firstleg:
|
||||||
inbound:
|
inbound:
|
||||||
allow-rejected-measurement: false
|
allow-rejected-measurement: false
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ spring:
|
|||||||
password: ${IAM_DB_PASSWORD}
|
password: ${IAM_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
baiyue:
|
baiyue:
|
||||||
|
security-event:
|
||||||
|
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
|
||||||
|
customer-dimension-initialization:
|
||||||
|
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
|
||||||
|
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
|
||||||
notification:
|
notification:
|
||||||
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
||||||
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
|
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
|
||||||
|
|||||||
@@ -21,3 +21,39 @@ baiyue:
|
|||||||
max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3}
|
max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3}
|
||||||
amazon-sp-api:
|
amazon-sp-api:
|
||||||
enabled: ${AMAZON_SP_API_ENABLED:false}
|
enabled: ${AMAZON_SP_API_ENABLED:false}
|
||||||
|
carrier-schedule:
|
||||||
|
matson:
|
||||||
|
enabled: ${MATSON_TRACKING_ENABLED:false}
|
||||||
|
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
|
||||||
|
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
|
||||||
|
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
hede:
|
||||||
|
enabled: ${HEDE_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
|
||||||
|
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
sml:
|
||||||
|
enabled: ${SML_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
|
||||||
|
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
zim:
|
||||||
|
enabled: ${ZIM_TRACKING_ENABLED:false}
|
||||||
|
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
|
||||||
|
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
|
||||||
|
client-id: ${ZIM_CLIENT_ID:}
|
||||||
|
client-secret: ${ZIM_CLIENT_SECRET:}
|
||||||
|
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
|
||||||
|
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
|
||||||
|
evergreen:
|
||||||
|
enabled: ${EMC_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
|
||||||
|
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ spring.datasource:
|
|||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
|
baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
|
||||||
|
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
|
||||||
|
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
|
||||||
|
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
|
||||||
|
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
|
||||||
|
baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
|
||||||
|
baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
|
||||||
firstleg:
|
firstleg:
|
||||||
inbound:
|
inbound:
|
||||||
allow-rejected-measurement: false
|
allow-rejected-measurement: false
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ spring.datasource:
|
|||||||
username: ${IAM_DB_USERNAME:root}
|
username: ${IAM_DB_USERNAME:root}
|
||||||
password: ${IAM_DB_PASSWORD:123456}
|
password: ${IAM_DB_PASSWORD:123456}
|
||||||
baiyue:
|
baiyue:
|
||||||
|
customer-dimension-initialization:
|
||||||
|
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
|
||||||
|
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
|
||||||
notification:
|
notification:
|
||||||
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
||||||
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
|
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
|
||||||
|
|||||||
@@ -15,3 +15,39 @@ baiyue:
|
|||||||
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
|
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
|
||||||
amazon-sp-api:
|
amazon-sp-api:
|
||||||
enabled: ${AMAZON_SP_API_ENABLED:false}
|
enabled: ${AMAZON_SP_API_ENABLED:false}
|
||||||
|
carrier-schedule:
|
||||||
|
matson:
|
||||||
|
enabled: ${MATSON_TRACKING_ENABLED:false}
|
||||||
|
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
|
||||||
|
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
|
||||||
|
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
hede:
|
||||||
|
enabled: ${HEDE_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
|
||||||
|
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
sml:
|
||||||
|
enabled: ${SML_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
|
||||||
|
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
zim:
|
||||||
|
enabled: ${ZIM_TRACKING_ENABLED:false}
|
||||||
|
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
|
||||||
|
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
|
||||||
|
client-id: ${ZIM_CLIENT_ID:}
|
||||||
|
client-secret: ${ZIM_CLIENT_SECRET:}
|
||||||
|
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
|
||||||
|
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
|
||||||
|
evergreen:
|
||||||
|
enabled: ${EMC_TRACKING_ENABLED:false}
|
||||||
|
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
|
||||||
|
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
|
||||||
|
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
|
||||||
|
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 海外 Nacos 配置
|
||||||
|
|
||||||
|
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
|
||||||
|
|
||||||
|
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${AUDIT_DB_URL}
|
||||||
|
username: ${AUDIT_DB_USERNAME}
|
||||||
|
password: ${AUDIT_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
baiyue:
|
||||||
|
deployment:
|
||||||
|
region-code: ${REGION_CODE:OVERSEAS}
|
||||||
|
cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||||
|
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||||
|
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}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
pda-session-validation:
|
||||||
|
auth-url: ${AUTH_SESSION_VALIDATION_URL}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
|
||||||
|
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
|
||||||
|
openapi.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
audit.enabled: true
|
||||||
|
spring:
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
deserialization:
|
||||||
|
fail-on-unknown-properties: false
|
||||||
|
serialization:
|
||||||
|
write-dates-as-timestamps: false
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:redis-overseas.internal}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:}
|
||||||
|
database: ${REDIS_DATABASE:0}
|
||||||
|
timeout: ${REDIS_TIMEOUT:3s}
|
||||||
|
cloud:
|
||||||
|
nacos:
|
||||||
|
discovery:
|
||||||
|
group: ${NACOS_GROUP:BAIYUE}
|
||||||
|
namespace: ${NACOS_NAMESPACE:prod-overseas}
|
||||||
|
openfeign:
|
||||||
|
circuitbreaker.enabled: true
|
||||||
|
client.config.default:
|
||||||
|
connect-timeout: 3000
|
||||||
|
read-timeout: 5000
|
||||||
|
server:
|
||||||
|
shutdown: graceful
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes.enabled: true
|
||||||
|
show-details: never
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
call-setters-on-nulls: true
|
||||||
|
global-config:
|
||||||
|
banner: false
|
||||||
|
db-config:
|
||||||
|
id-type: assign_id
|
||||||
|
logic-delete-field: deleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
springdoc:
|
||||||
|
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${EVENT_BRIDGE_DB_URL}
|
||||||
|
username: ${EVENT_BRIDGE_DB_USERNAME}
|
||||||
|
password: ${EVENT_BRIDGE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
baiyue:
|
||||||
|
region: ${REGION_CODE:OVERSEAS}
|
||||||
|
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||||
|
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||||
|
event-dispatcher:
|
||||||
|
consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
|
||||||
|
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service: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}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${FILE_DB_URL}
|
||||||
|
username: ${FILE_DB_USERNAME}
|
||||||
|
password: ${FILE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
file:
|
||||||
|
storage:
|
||||||
|
provider: ${FILE_STORAGE_PROVIDER:s3}
|
||||||
|
endpoint: ${FILE_STORAGE_ENDPOINT:}
|
||||||
|
bucket: ${FILE_STORAGE_BUCKET:}
|
||||||
|
access-key: ${FILE_STORAGE_ACCESS_KEY:}
|
||||||
|
secret-key: ${FILE_STORAGE_SECRET_KEY:}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
springdoc:
|
||||||
|
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
baiyue:
|
||||||
|
security:
|
||||||
|
overseas-projection:
|
||||||
|
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
baiyue:
|
||||||
|
integration:
|
||||||
|
baidu-ocr:
|
||||||
|
enabled: ${BAIDU_OCR_ENABLED:false}
|
||||||
|
amazon-sp-api:
|
||||||
|
enabled: ${AMAZON_SP_API_ENABLED:true}
|
||||||
|
client-id: ${AMAZON_SP_API_CLIENT_ID:}
|
||||||
|
client-secret: ${AMAZON_SP_API_CLIENT_SECRET:}
|
||||||
|
refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:}
|
||||||
|
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
|
||||||
|
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
|
||||||
|
region: ${AMAZON_SP_API_REGION:}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${LASTLEG_DB_URL}
|
||||||
|
username: ${LASTLEG_DB_USERNAME}
|
||||||
|
password: ${LASTLEG_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
lastleg:
|
||||||
|
dispatch-query-api:
|
||||||
|
enabled: true
|
||||||
|
smart-dispatch-api:
|
||||||
|
enabled: true
|
||||||
|
baiyue:
|
||||||
|
http:
|
||||||
|
clients:
|
||||||
|
unload-photo-file:
|
||||||
|
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
|
||||||
|
relabel-photo-file:
|
||||||
|
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
|
||||||
|
warehouse-resource:
|
||||||
|
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
|
||||||
|
pallet-physical:
|
||||||
|
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||||
|
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||||
|
clients:
|
||||||
|
domestic-warehouse:
|
||||||
|
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||||
|
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||||
|
file:
|
||||||
|
url: ${FILE_SERVICE_URL}
|
||||||
|
event-bridge:
|
||||||
|
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
|
||||||
|
warehouse:
|
||||||
|
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
|
||||||
|
firstleg:
|
||||||
|
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
|
||||||
|
outbox-relay:
|
||||||
|
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||||
|
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||||
|
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${PRINT_DB_URL}
|
||||||
|
username: ${PRINT_DB_USERNAME}
|
||||||
|
password: ${PRINT_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${WAREHOUSE_DB_URL}
|
||||||
|
username: ${WAREHOUSE_DB_USERNAME}
|
||||||
|
password: ${WAREHOUSE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
baiyue:
|
||||||
|
clients:
|
||||||
|
domestic-warehouse:
|
||||||
|
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||||
|
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Auth signs short-lived overseas RS256 tokens with a private JWK Set.
|
||||||
|
OVERSEAS_JWT_ENABLED=true
|
||||||
|
OVERSEAS_JWT_ISSUER=auth-service
|
||||||
|
OVERSEAS_JWT_AUDIENCE=lastleg-api
|
||||||
|
OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid
|
||||||
|
OVERSEAS_ACCESS_TOKEN_TTL=15m
|
||||||
|
OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]}
|
||||||
|
|
||||||
|
# Overseas services validate tokens against Auth's public JWKS endpoint.
|
||||||
|
JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json
|
||||||
|
JWT_ISSUER=auth-service
|
||||||
|
JWT_AUDIENCE=lastleg-api
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
name: firstleg-${DEPLOY_ENV:-test}-overseas
|
||||||
|
|
||||||
|
x-overseas-common: &overseas-common
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [firstleg-overseas-network, infra-network]
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
environment: &overseas-environment
|
||||||
|
SPRING_PROFILES_ACTIVE: overseas
|
||||||
|
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||||
|
NACOS_USERNAME: ${NACOS_USERNAME}
|
||||||
|
NACOS_PASSWORD: ${NACOS_PASSWORD}
|
||||||
|
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
|
||||||
|
NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas}
|
||||||
|
REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal}
|
||||||
|
REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379}
|
||||||
|
REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-}
|
||||||
|
REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0}
|
||||||
|
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
JWT_ISSUER: ${JWT_ISSUER}
|
||||||
|
JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api}
|
||||||
|
JWT_JWK_SET_URI: ${JWT_JWK_SET_URI}
|
||||||
|
REGION_CODE: OVERSEAS
|
||||||
|
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
||||||
|
DATA_RESIDENCY: OVERSEAS
|
||||||
|
SPRING_SQL_INIT_MODE: never
|
||||||
|
LOG_PATH: /app/logs
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 100m
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
services:
|
||||||
|
gateway-overseas:
|
||||||
|
<<: *overseas-common
|
||||||
|
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||||
|
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||||
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||||
|
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
|
||||||
|
environment:
|
||||||
|
<<: *overseas-environment
|
||||||
|
SERVER_PORT: 9300
|
||||||
|
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
|
||||||
|
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
||||||
|
AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL}
|
||||||
|
PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s}
|
||||||
|
LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303}
|
||||||
|
LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s}
|
||||||
|
|
||||||
|
lastleg-service:
|
||||||
|
<<: *overseas-common
|
||||||
|
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||||
|
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||||
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
||||||
|
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
|
||||||
|
environment:
|
||||||
|
<<: *overseas-environment
|
||||||
|
SERVER_PORT: 9303
|
||||||
|
LASTLEG_DB_URL: ${LASTLEG_DB_URL}
|
||||||
|
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
|
||||||
|
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
|
||||||
|
CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||||
|
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||||
|
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||||
|
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
|
||||||
|
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
||||||
|
|
||||||
|
warehouse-overseas-service:
|
||||||
|
<<: *overseas-common
|
||||||
|
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||||
|
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||||
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||||
|
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
|
||||||
|
environment:
|
||||||
|
<<: *overseas-environment
|
||||||
|
SERVER_PORT: 9305
|
||||||
|
WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}
|
||||||
|
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
|
||||||
|
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
|
||||||
|
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||||
|
|
||||||
|
event-bridge-service:
|
||||||
|
<<: *overseas-common
|
||||||
|
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||||
|
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||||
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||||
|
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
|
||||||
|
environment:
|
||||||
|
<<: *overseas-environment
|
||||||
|
SERVER_PORT: 9312
|
||||||
|
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL}
|
||||||
|
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
|
||||||
|
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
|
||||||
|
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events}
|
||||||
|
LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events}
|
||||||
|
networks:
|
||||||
|
firstleg-overseas-network:
|
||||||
|
infra-network:
|
||||||
|
aliases: [event-bridge-overseas]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
firstleg-overseas-network:
|
||||||
|
name: firstleg-${DEPLOY_ENV:-test}-overseas-network
|
||||||
|
infra-network:
|
||||||
|
external: true
|
||||||
|
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user