This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
-2
View File
@@ -89,7 +89,6 @@ OpenAPI有效性:
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
@@ -102,4 +101,3 @@ OpenAPI有效性:
- 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
+13 -11
View File
@@ -2,18 +2,20 @@
> 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 > 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。
根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。
## 适用范围与定位 ## 适用范围与定位
- 本文件适用于 `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`。本文件只保留后端执行所需摘要。 - 平台/客户层级与权限公共语义见 `../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`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位) ## 服务路由(按关键词定位)
@@ -51,9 +53,9 @@
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` |
| 目的港码头拖车与还空预警 | `firstleg/` | | 目的港码头拖车与还空预警 | `firstleg/` |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | | 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | | 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` |
@@ -62,8 +64,8 @@
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | | 资质、导出、导出退避重试、测量图片、文件下载 | `file/` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | | 路由、CORS、JWT 入口、访问域 | `gateway/` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | | 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | | 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` |
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | | 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` |
@@ -76,7 +78,7 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | | PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
## 公共模块快速索引 ## 公共模块快速索引
@@ -97,8 +99,8 @@
## 国内头程与海外仓协同 ## 国内头程与海外仓协同
- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。原双 Token 与 Lastleg 安全投影在替代撤销链验证后移除。 - Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;现有 Bridge 尚未退役,不得先停服务后补替代链。 - 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 契约、数据库与验证入口 ## 契约、数据库与验证入口
@@ -113,7 +115,7 @@
- 权限语义变更必须同步 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/test/`;服务日志、上传文件、`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`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
@@ -139,7 +141,7 @@
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 - 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取
- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
Vendored
+16 -187
View File
@@ -1,11 +1,10 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-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',
@@ -17,20 +16,19 @@ def serviceCheckboxes = [
'integration-service': 'SERVICE_INTEGRATION', 'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG', 'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT' 'audit-service': 'SERVICE_AUDIT'
] ]
def fullRebuildPaths = [ def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml' 'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile'
] ]
def commonPathServices = [ def commonPathServices = [
'common-web/': [ 'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service' 'audit-service'
], ],
'common-security/': [ 'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
@@ -39,20 +37,16 @@ def commonPathServices = [
'common-openapi/': [ 'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service' 'audit-service'
], ],
'common-audit/': [ 'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service' 'warehouse-overseas-service', 'audit-service'
], ],
'common-event-contract/': [ 'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' 'firstleg', 'finance', 'lastleg-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.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
] ]
def databaseMigrationPaths = ['deploy/sql/'] def databaseMigrationPaths = ['deploy/sql/']
@@ -63,45 +57,6 @@ def downstreamResults = [:]
def downstreamBuildUrls = [:] def downstreamBuildUrls = [:]
def downstreamBuildNumbers = [:] def downstreamBuildNumbers = [:]
def sendProductionWeComNotification(String title, String status, String color, String detail) {
node('java-builder') {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析'
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
String content = """## ${title}
> 应用:后端服务
> Job:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:<font color="${color}">${status}</font>
${detail}
> 耗时:${duration}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
writeFile(
file: payloadFile,
text: groovy.json.JsonOutput.toJson([
msgtype: 'markdown',
markdown: [content: content]
]),
encoding: 'UTF-8'
)
sh '''
set -eu
payload_file='wecom-production-notification.json'
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
response="$(curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data-binary @"$payload_file" \
"$WECOM_WEBHOOK")"
printf '%s\n' "$response"
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
'''
}
}
}
def parseAutoBuildState(String description, List<String> allowedServices) { def parseAutoBuildState(String description, List<String> allowedServices) {
if (!description?.startsWith('AUTO ')) return null if (!description?.startsWith('AUTO ')) return null
int markerIndex = description.indexOf(' | ') int markerIndex = description.indexOf(' | ')
@@ -154,12 +109,8 @@ def environmentChoicesForJob(String jobBaseName) {
def fixedEnvironmentForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) {
switch (jobBaseName) { switch (jobBaseName) {
case 'backend-java-dev':
return 'development'
case 'backend-java': case 'backend-java':
return 'test' return 'test'
case 'backend-java-prod':
return 'production'
default: default:
return null return null
} }
@@ -167,12 +118,8 @@ def fixedEnvironmentForJob(String jobBaseName) {
def fixedDownstreamJobPrefixForJob(String jobBaseName) { def fixedDownstreamJobPrefixForJob(String jobBaseName) {
switch (jobBaseName) { switch (jobBaseName) {
case 'backend-java-dev':
return 'baiyue-firstleg-dev-'
case 'backend-java': case 'backend-java':
return 'baiyue-firstleg-' return 'baiyue-firstleg-'
case 'backend-java-prod':
return 'baiyue-firstleg-prod-'
default: default:
return null return null
} }
@@ -195,9 +142,8 @@ 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)')
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
@@ -207,14 +153,12 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-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: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址') string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
} }
@@ -230,7 +174,7 @@ pipeline {
} }
if (params.ENVIRONMENT != resolvedEnvironment || if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" error('当前流水线仅允许 TEST 与 baiyue-firstleg- 下游前缀')
} }
env.RESOLVED_ENVIRONMENT = resolvedEnvironment env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
@@ -239,16 +183,7 @@ pipeline {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() 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() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2
exit 1
}
'''
}
String requestedRepository = params.IMAGE_REPOSITORY?.trim() String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
@@ -262,9 +197,7 @@ pipeline {
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
env.DATABASE_CHANGE_DETECTED = 'false' env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
List selected = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
@@ -326,15 +259,7 @@ 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 (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) {
@@ -426,14 +351,6 @@ 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,
@@ -441,16 +358,13 @@ 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: downstreamImageTag), string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
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: true), 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'
@@ -483,89 +397,4 @@ pipeline {
} }
} }
post {
success {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') {
try {
List deployedServices = env.SELECTED_SERVICES?.split(',')
?.collect { it.trim() }
?.findAll { it } ?: []
String serviceSummary = deployedServices.size() <= 3
? deployedServices.join('、')
: "${deployedServices.size()} 个服务"
sendProductionWeComNotification(
'✅ 生产部署成功',
'成功',
'info',
"> 结果:${deployedServices.size()} 个服务全部成功\n" +
"> 部署服务:${serviceSummary ?: '—'}"
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
unstable {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' }
.collect { service, result -> service }
int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size()
String failedLinks = failedServices.collect { service ->
String url = downstreamBuildUrls[service]
String number = downstreamBuildNumbers[service]
String label = number ? "${service} #${number}" : service
url ? "[${label}](${url})" : label
}.join('、')
sendProductionWeComNotification(
'⚠️ 生产部署部分失败',
'部分失败',
'warning',
"> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" +
"> 失败服务:${failedLinks ?: '未解析'}\n" +
'> 说明:点击失败服务可直接查看下游构建日志'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
failure {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
'❌ 生产发布失败',
'失败',
'warning',
'> 说明:生产发布流程失败,请打开父构建日志查看失败阶段'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
aborted {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
'⏹ 生产部署已取消',
'已取消',
'comment',
'> 说明:生产部署流程被人工取消或中止'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
}
} }
+18 -128
View File
@@ -1,11 +1,10 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-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',
@@ -17,18 +16,11 @@ def serviceJars = [
'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', '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', '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',
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
] ]
def fixedEnvironmentForServiceJob(String jobBaseName) { def fixedEnvironmentForServiceJob(String jobBaseName) {
if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) {
return 'development'
}
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) {
return 'production'
}
if (jobBaseName?.startsWith('baiyue-firstleg-')) {
return 'test' return 'test'
} }
return null return null
@@ -53,11 +45,11 @@ pipeline {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
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: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
} }
environment { environment {
@@ -98,12 +90,7 @@ pipeline {
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
} }
if (params.ENVIRONMENT != resolvedEnvironment) { if (params.ENVIRONMENT != resolvedEnvironment) {
echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" error('当前单服务流水线仅允许 TEST')
}
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')
@@ -123,14 +110,8 @@ 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')
}
}
} }
sh ''' sh '''
@@ -142,15 +123,6 @@ pipeline {
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
''' '''
sh '''
if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || {
echo "生产构建只允许已经合并到 main 的提交" >&2
exit 1
}
fi
'''
script { script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
@@ -163,15 +135,11 @@ pipeline {
steps { steps {
script { script {
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') boolean skipTestExecution = params.SKIP_TESTS
}
boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : '' String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) { if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (env.RESOLVED_ENVIRONMENT == 'test') {
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}"
} }
@@ -216,105 +184,27 @@ pipeline {
} }
} }
stage('部署开发或测试环境') { stage('部署 TEST') {
when { when { expression { params.DEPLOY } }
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
}
agent none agent none
steps { steps {
script { script {
String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" node('java-test') {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
lock(resource: serviceDeployResource, inversePrecedence: true) {
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
node(targetLabel) {
deleteDir()
try {
unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}"
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 '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
}
}
}
stage('部署生产环境单个服务') {
when {
beforeAgent true
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' }
}
agent { label 'java-builder' }
steps {
deleteDir() deleteDir()
unstash 'deployment-files' unstash 'deployment-files'
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy"
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]
lock(resource: serviceDeployResource, inversePrecedence: true) {
withCredentials([ withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) { ]) {
bridgeRegions.each { bridgeRegion -> withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
if (env.RESOLVED_SERVICE == 'event-bridge-service') { sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
echo "部署生产事件桥实例: ${bridgeRegion}"
}
withEnv([
'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services',
'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
} }
} }
} }
} }
} }
} }
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
} }
} }
+10 -11
View File
@@ -2,20 +2,21 @@
本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。
整体迁移尚未完成:下面模块清单与旧启动配置描述的是现有源码,海外 Gateway、事件桥和双 Token 仍待替代;不代表需要继续规划双区域或其他环境。 根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
## 模块 ## 模块
- `gateway`:四端统一 API 入口。
- `finance`:财务账单、费用、充值、授信与资金核销。
- `auth`:统一登录、令牌签发与刷新。 - `auth`:统一登录、令牌签发与刷新。
- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
- `order`:订单主数据及订单状态编排。 - `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。 - `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。 - `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:统一管理仓库内公共依赖版本。 - `platform-bom`:统一管理仓库内公共依赖版本。
@@ -31,21 +32,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
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`。旧海外 Compose/Nacos 仍存在;新的测试启动依赖和统一配置按部署主契约收敛,本地编译不代表另有开发环境。 唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
## 容器日志 ## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: 单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖:
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | | 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|---|---|---:|---:|---| |---|---|---:|---:|---|
| 开发 | `json-file` | `20m` | `2` | 不要求 |
| 测试 | `json-file` | `50m` | `3` | 不要求 | | 测试 | `json-file` | `50m` | `3` | 不要求 |
| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 |
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
@@ -53,7 +50,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
## 网关跨域配置 ## 网关跨域配置
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: 本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
```text ```text
CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
@@ -64,3 +61,5 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界 ## 仓库边界
仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。
前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。
+1 -1
View File
@@ -17,7 +17,7 @@
| 入库编排 | `service/AuditIngestService.java` | | 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
| 表结构 | `src/main/resources/db/audit.sql` | | 表结构 | `src/main/resources/db/audit.sql` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` |
## 修改与验证 ## 修改与验证
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUDIT_DB_USERNAME:root}
password: ${AUDIT_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
@@ -3,27 +3,27 @@ server:
spring: spring:
profiles: profiles:
active: ${SPRING_PROFILES_ACTIVE:dev} active: ${SPRING_PROFILES_ACTIVE:test}
application: application:
name: audit-service name: audit-service
config: config:
import: import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
cloud: cloud:
nacos: nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME:nacos} username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD}
discovery: discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
enabled: ${NACOS_DISCOVERY_ENABLED:true} enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
config: config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
baiyue.audit.enabled: false baiyue.audit.enabled: false
baiyue: baiyue:
+4 -2
View File
@@ -18,7 +18,7 @@
| 统一 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 会话校验 |
| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | | 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT |
| 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` |
@@ -28,7 +28,9 @@
## 修改约束 ## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。
- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
+1
View File
@@ -22,6 +22,7 @@
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency> <dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency> <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies> </dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build> <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project> </project>
@@ -0,0 +1,14 @@
package com.baiyue.auth.client;
import feign.Request;
import feign.Retryer;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/** Only the IAM authorization client uses this bounded, single-attempt chain. */
public class AuthorizationFeignConfiguration {
@Bean public Request.Options authorizationRequestOptions() {
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false);
}
@Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; }
}
@@ -23,6 +23,14 @@ public class AuthorizationSnapshot {
private Long permissionVersion; private Long permissionVersion;
private Long overseasWarehouseId; private Long overseasWarehouseId;
private Long domesticWarehouseId; private Long domesticWarehouseId;
private Long tenantVersion;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions, Long permissionVersion,
Long overseasWarehouseId, Long domesticWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L);
}
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions, String clientType, Set<String> roles, Set<String> permissions,
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
/** /**
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
*/ */
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", @FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class,
url = "${baiyue.clients.iam.url:}", path = "/internal/iam") url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamAuthorizationClient { public interface IamAuthorizationClient {
@PostMapping("/authorization-snapshot") @PostMapping("/authorization-snapshot")
@@ -3,15 +3,12 @@ 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 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.*;
@@ -22,14 +19,13 @@ 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") @Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified 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 @Resource private com.baiyue.auth.service.SessionStateService sessionStateService;
private OverseasSessionValidationService overseasSessionValidationService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -71,15 +67,17 @@ 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") public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId,
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", @jakarta.validation.constraints.NotNull Long userId) { }
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession( @PostMapping("/sessions/validate")
public ApiResponse<Boolean> validateSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) { @Valid @RequestBody SessionValidationRequest request) {
verify(token); verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request)); return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId()));
} }
@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) {
@@ -1,29 +0,0 @@
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
}
}
@@ -19,13 +19,13 @@ public class AuthAccountRepository {
public AuthAccount findByUsername(String username) { public AuthAccount findByUsername(String username) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>() return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username) .eq(AuthAccount::getUsername, username)
.last("LIMIT 1")); .last("LIMIT 1 FOR UPDATE"));
} }
public AuthAccount findByPhone(String phone) { public AuthAccount findByPhone(String phone) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>() return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone) .eq(AuthAccount::getPhone, phone)
.last("LIMIT 1")); .last("LIMIT 1 FOR UPDATE"));
} }
public boolean existsByUsername(String username) { public boolean existsByUsername(String username) {
@@ -53,13 +53,15 @@ public class AuthAccountRepository {
public void insert(AuthAccount account) { mapper.insert(account); } public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) { public AuthAccount findById(Long id) {
return mapper.selectById(id); return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>().eq(AuthAccount::getId, id).last("FOR UPDATE"));
} }
public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); }
public List<AuthAccount> findMerchantByTenant(Long tenantId) { public List<AuthAccount> findMerchantByTenant(Long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>() return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getDefaultTenantId, tenantId) .eq(AuthAccount::getDefaultTenantId, tenantId)
.eq(AuthAccount::getDefaultAppCode, "MERCHANT")); .eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE"));
} }
public void save(AuthAccount account) { public void save(AuthAccount account) {
@@ -108,7 +108,7 @@ public class AccountManagementService {
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false) captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) { public void revokeSessions(Set<Long> accountIds) {
accountIds.forEach(accountId -> bumpVersion(required(accountId))); accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
} }
@Transactional @Transactional
@@ -92,7 +92,8 @@ public class AuthService {
AuthAccount account = accountRepository.findById(session.userId()); AuthAccount account = accountRepository.findById(session.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
return issue(account); if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
return issue(account, session.fingerprint());
} }
@Transactional @Transactional
@@ -109,8 +110,13 @@ public class AuthService {
accountRepository.save(account); accountRepository.save(account);
} }
private TokenResponse issue(AuthAccount account) { private TokenResponse issue(AuthAccount account) { return issue(account, null); }
private TokenResponse issue(AuthAccount account, String expectedState) {
AuthorizationSnapshot authorization = authorizationLoader.load(account); AuthorizationSnapshot authorization = authorizationLoader.load(account);
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
}
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());
@@ -145,7 +151,8 @@ public class AuthService {
byte[] random = new byte[48]; byte[] random = new byte[48];
secureRandom.nextBytes(random); secureRandom.nextBytes(random);
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(),
SessionStateService.fingerprint(account, authorization), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl()); properties.accessTokenTtl(), properties.refreshTokenTtl());
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(),
@@ -202,7 +209,7 @@ public class AuthService {
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15)); authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900, return new ImpersonationTokenResponse("Bearer", accessToken, 900,
authorization.getRoles(), authorization.getPermissions()); authorization.getRoles(), authorization.getPermissions());
} }
@@ -1,38 +0,0 @@
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,63 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
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 com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.TreeSet;
/** No cache: a validation started after a security commit must observe that commit. */
@Service
public class SessionStateService {
private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper();
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private TokenSessionStore sessions;
public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) {
try {
if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null
|| snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version");
// Ordered JSON array avoids set ordering and delimiter ambiguities.
Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(),
account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(),
snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(),
snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(),
new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())};
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(FINGERPRINT_JSON.writeValueAsBytes(state)));
} catch (Exception failure) {
throw new IllegalStateException("Cannot fingerprint authorization state", failure);
}
}
private void requireCurrent(Long userId, String expected) {
AuthAccount account = accounts.findCurrentById(userId);
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|| Integer.valueOf(1).equals(account.getDeleted()) || expected == null
|| !expected.equals(fingerprint(account, authorizationLoader.load(account)))) {
throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录");
}
}
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW,
isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public boolean validate(String jwtId, Long userId) {
TokenSessionStore.SessionState state = sessions.loadState(jwtId);
if (state == null || !state.userId().equals(userId)) {
throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效");
}
requireCurrent(userId, state.fingerprint());
return true;
}
}
@@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -21,8 +19,6 @@ import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Set; import java.util.Set;
import java.util.ArrayList;
import java.util.List;
/** /**
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
@@ -42,7 +38,7 @@ public class TokenSessionStore {
} }
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions, public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId); String userKey = userSessionsKey(userId);
@@ -50,25 +46,27 @@ public class TokenSessionStore {
String accessValue = objectMapper.writeValueAsString( String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions))); new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); storeState(jwtId, userId, fingerprint, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint));
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet(); SetOperations<String, String> sets = redisTemplate.opsForSet();
if (sets != null) sets.add(userKey, accessKey, refreshKey); if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId));
redisTemplate.expire(userKey, refreshTtl); redisTemplate.expire(userKey, refreshTtl);
} catch (Exception exception) { } catch (Exception exception) {
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { } try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { }
throw unavailable(exception); throw unavailable(exception);
} }
} }
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles, public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) { Set<String> permissions, String fingerprint, Duration ttl) {
try { try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String accessValue = objectMapper.writeValueAsString( String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions))); new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, ttl); redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); storeState(jwtId, userId, fingerprint, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId));
redisTemplate.expire(userSessionsKey(userId), ttl); redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) { } catch (Exception exception) {
throw unavailable(exception); throw unavailable(exception);
@@ -103,6 +101,7 @@ public class TokenSessionStore {
if (value == null || value.isBlank()) return null; if (value == null || value.isBlank()) return null;
RefreshSession session = objectMapper.readValue(value, RefreshSession.class); RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
redisTemplate.delete(stateKey(session.accessJwtId()));
return session; return session;
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw unavailable(exception); throw unavailable(exception);
@@ -121,43 +120,26 @@ public class TokenSessionStore {
try { try {
Set<String> keys = redisTemplate.opsForSet().members(userKey); Set<String> keys = redisTemplate.opsForSet().members(userKey);
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
List<String> legacyKeys = new ArrayList<>();
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
redisTemplate.delete(userKey); redisTemplate.delete(userKey);
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw unavailable(exception); throw unavailable(exception);
} }
} }
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) { private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; }
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
if (cursor == null) return; private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception {
try (cursor) { if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state");
while (cursor.hasNext()) { redisTemplate.opsForValue().set(stateKey(jwtId),
String key = cursor.next(); objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl);
String value = redisTemplate.opsForValue().get(key);
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
result.add(key);
}
}
} catch (Exception exception) {
throw unavailable(exception);
}
} }
private boolean belongsTo(String value, Long userId) { public SessionState loadState(String jwtId) {
if (value == null || value.isBlank()) return false; try {
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null;
catch (Exception ignored) { return false; } String value = redisTemplate.opsForValue().get(stateKey(jwtId));
} return value == null ? null : objectMapper.readValue(value, SessionState.class);
} catch (Exception failure) { throw unavailable(failure); }
private boolean accessBelongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
catch (Exception ignored) { return false; }
} }
private static Set<String> normalized(Set<String> values) { private static Set<String> normalized(Set<String> values) {
@@ -182,7 +164,8 @@ public class TokenSessionStore {
} }
} }
public record RefreshSession(Long userId, String accessJwtId) { } public record SessionState(Long userId, String fingerprint) { }
public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { }
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) { public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession { public AccessSession {
roles = normalized(roles); roles = normalized(roles);
@@ -1,10 +0,0 @@
spring:
cloud:
nacos:
discovery:
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456}
@@ -1,15 +0,0 @@
spring:
cloud:
nacos:
config:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
encode: UTF-8
discovery:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
+13 -11
View File
@@ -2,35 +2,35 @@ server:
port: ${AUTH_PORT:9100} port: ${AUTH_PORT:9100}
spring: spring:
profiles: profiles:
active: ${SPRING_PROFILES_ACTIVE:dev} active: ${SPRING_PROFILES_ACTIVE:test}
application: application:
name: auth-service name: auth-service
config: config:
import: import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
data: data:
redis: redis:
host: ${REDIS_HOST:127.0.0.1} host: ${REDIS_HOST}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0} database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s} timeout: ${REDIS_TIMEOUT:3s}
cloud: cloud:
nacos: nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME:nacos} username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD}
discovery: discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true} enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
config: config:
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
encode: UTF-8 encode: UTF-8
baiyue.security: baiyue.security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} internal-service-token: ${INTERNAL_SERVICE_TOKEN}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api} audience: ${JWT_AUDIENCE:lastleg-api}
@@ -40,7 +40,7 @@ baiyue.security:
lock-duration: ${LOGIN_LOCK_DURATION:15m} lock-duration: ${LOGIN_LOCK_DURATION:15m}
jwt-mode: jwks jwt-mode: jwks
session-mode: redis session-mode: redis
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
jwt: jwt:
rsa-jwk-set: ${JWT_RSA_JWK_SET:} rsa-jwk-set: ${JWT_RSA_JWK_SET:}
current-kid: ${JWT_CURRENT_KID:} current-kid: ${JWT_CURRENT_KID:}
@@ -50,3 +50,5 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:} url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
@@ -0,0 +1,39 @@
openapi: 3.0.3
info:
title: Auth internal session validation
version: 1.0.0
paths:
/internal/auth/accounts/sessions/validate:
post:
summary: Validate persisted account and IAM authorization state
description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token.
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jwtId, userId]
properties:
jwtId: {type: string, minLength: 1}
userId: {type: integer, format: int64}
responses:
'200':
description: Only string code 200 and boolean data true constitute success. Business errors deny access.
content:
application/json:
schema:
type: object
required: [code]
properties:
code: {type: string}
message: {type: string}
data: {type: boolean, nullable: true}
components:
securitySchemes:
internalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
@@ -1,28 +0,0 @@
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");
}
}
@@ -93,7 +93,7 @@ class AuthServiceSmallJwtTest {
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), 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)));
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
@@ -156,7 +156,7 @@ class AuthServiceSmallJwtTest {
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn( when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session")); new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot)));
when(repository.findById(52L)).thenReturn(account); when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
@@ -175,7 +175,7 @@ class AuthServiceSmallJwtTest {
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(response.refreshToken()), anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()),
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(authorizationLoader).load(account); verify(authorizationLoader).load(account);
@@ -231,6 +231,6 @@ class AuthServiceSmallJwtTest {
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
} }
} }
@@ -1,77 +0,0 @@
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("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 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,281 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.AuthSecurityProperties;
import com.baiyue.auth.config.UnifiedJwtProperties;
import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest;
import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services.
* Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */
class SessionStateConcurrencyTest {
JdbcTemplate authDb, iamDb;
DataSourceTransactionManager authTx, iamTx;
AuthService auth;
AccountManagementService accounts;
SessionStateService states;
TokenSessionStore sessions;
JwtEncoder encoder;
AtomicBoolean redisFailure;
final ConcurrentMap<String, String> values = new ConcurrentHashMap<>();
final ConcurrentMap<String, Set<String>> sets = new ConcurrentHashMap<>();
@BeforeEach void setup() throws Exception {
authDb = database(); iamDb = database();
authTx = new DataSourceTransactionManager(authDb.getDataSource());
iamTx = new DataSourceTransactionManager(iamDb.getDataSource());
authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50),"
+ "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT,"
+ "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT,"
+ "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT,"
+ "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id,"
+ "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)");
iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)");
iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)");
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
factory.setDataSource(authDb.getDataSource());
var config = new com.baomidou.mybatisplus.core.MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
factory.setConfiguration(config);
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject())
.getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
var repository = new AuthAccountRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
AuthorizationLoader loader = mock(AuthorizationLoader.class);
when(loader.load(any())).thenAnswer(call -> {
var tx = new TransactionTemplate(iamTx);
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ);
return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> {
if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled");
return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"),
Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null,
rs.getLong("tenant_version"));
}));
});
sessions = new TokenSessionStore();
ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(sessions, "keyPrefix", "test");
ReflectionTestUtils.setField(sessions, "redisTemplate", redis());
PasswordEncoder passwords = mock(PasswordEncoder.class);
when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1)));
when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0));
encoder = mock(JwtEncoder.class);
when(encoder.encode(any())).thenAnswer(c -> {
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
});
var service = new AuthService();
ReflectionTestUtils.setField(service, "accountRepository", repository);
ReflectionTestUtils.setField(service, "authorizationLoader", loader);
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
ReflectionTestUtils.setField(service, "passwordEncoder", passwords);
ReflectionTestUtils.setField(service, "jwtEncoder", encoder);
ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1)));
ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid"));
auth = transactional(service);
var management = new AccountManagementService();
ReflectionTestUtils.setField(management, "repository", repository);
ReflectionTestUtils.setField(management, "tokenSessionStore", sessions);
ReflectionTestUtils.setField(management, "passwordEncoder", passwords);
accounts = transactional(management);
var state = new SessionStateService();
ReflectionTestUtils.setField(state, "accounts", repository);
ReflectionTestUtils.setField(state, "authorizationLoader", loader);
ReflectionTestUtils.setField(state, "sessions", sessions);
states = transactional(state);
}
@Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception {
var revoked = new CountDownLatch(1);
var snapshotLoaded = new CountDownLatch(1);
var committed = new CountDownLatch(1);
doAnswer(c -> {
snapshotLoaded.countDown(); await(committed);
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
}).when(encoder).encode(any());
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<?> change = pool.submit(() -> {
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
accounts.revokeSessions(Set.of(1L));
revoked.countDown(); await(snapshotLoaded);
});
committed.countDown();
});
await(revoked);
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS);
// Access and state were written AFTER revoke, yet the first post-commit request is denied.
assertThat(values).containsKey("test:access:" + stale.accessToken());
assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
var fresh = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
assertThat(fresh.authorities()).containsExactly("read");
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
} finally { committed.countDown(); pool.shutdownNow(); }
}
@Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception {
var loaded = new CountDownLatch(1); var release = new CountDownLatch(1);
doAnswer(c -> {
loaded.countDown(); await(release);
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
}).when(encoder).encode(any());
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
await(loaded);
var resetting = new CountDownLatch(1);
var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); });
await(resetting);
assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
release.countDown();
var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS);
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
} finally { release.countDown(); pool.shutdownNow(); }
}
@Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() {
var old = auth.login(new LoginRequest("operator", "old-password"));
redisFailure.set(true);
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1");
accounts.revokeSessions(Set.of(1L));
})).isInstanceOf(BizException.class);
redisFailure.set(false);
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1);
assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1);
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
}
@Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() {
var old = auth.login(new LoginRequest("operator", "old-password"));
new TransactionTemplate(iamTx).executeWithoutResult(tx ->
iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1"));
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
var current = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(current.accessToken(), 1L)).isTrue();
iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1");
assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class);
}
@Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() {
var old = auth.login(new LoginRequest("operator", "old-password"));
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
tx.setRollbackOnly();
});
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class);
var refreshed = auth.refresh(old.refreshToken());
assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue();
}
@Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() {
var old = auth.login(new LoginRequest("operator", "old-password"));
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
accounts.revokeSessions(Set.of(1L));
throw new IllegalStateException("Auth response timed out after its commit");
})).isInstanceOf(IllegalStateException.class);
assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write");
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
var fresh = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
assertThat(fresh.authorities()).containsExactly("write");
}
@Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() {
var old = auth.login(new LoginRequest("operator", "old-password"));
// No Redis deletion: the authoritative identity gate alone closes the session.
iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1");
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class);
iamDb.update("UPDATE authorization_state SET active=true WHERE id=1");
var fresh = auth.login(new LoginRequest("operator", "old-password"));
iamDb.execute("DROP TABLE authorization_state");
assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class);
assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class);
}
@SuppressWarnings("unchecked") private StringRedisTemplate redis() {
redisFailure = new AtomicBoolean();
var redis = mock(StringRedisTemplate.class);
ValueOperations<String,String> ops = mock(ValueOperations.class);
SetOperations<String,String> index = mock(SetOperations.class);
when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index);
doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class));
when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0)));
when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)));
when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0)));
when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null);
when(redis.delete(anyCollection())).thenAnswer(c -> {
if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline");
long count = 0; for (Object key : (java.util.Collection<?>) c.getArgument(0)) if (values.remove(key) != null) count++;
return count;
});
when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of()));
doAnswer(c -> {
Set<String> members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet());
for (int i=1; i<c.getArguments().length; i++) members.add(c.getArgument(i));
return 1L;
}).when(index).add(anyString(), any(String[].class));
return redis;
}
static JdbcTemplate database() {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=3000");
return new JdbcTemplate(ds);
}
@SuppressWarnings("unchecked") <T> T transactional(T target) {
var factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource()));
return (T) factory.getProxy();
}
static void await(CountDownLatch latch) {
try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); }
}
}
@@ -47,7 +47,7 @@ class TokenSessionStoreTest {
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); "state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
verify(values).set(eq("baiyue:auth:access:jti-1"), verify(values).set(eq("baiyue:auth:access:jti-1"),
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
@@ -64,7 +64,7 @@ class TokenSessionStoreTest {
ValueOperations<String, String> values = mock(ValueOperations.class); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
String json = new ObjectMapper().writeValueAsString( String json = new ObjectMapper().writeValueAsString(
new TokenSessionStore.RefreshSession(42L, "jti-1")); new TokenSessionStore.RefreshSession(42L, "jti-1", "state"));
when(values.getAndDelete(anyString())).thenReturn(json); when(values.getAndDelete(anyString())).thenReturn(json);
TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
+1
View File
@@ -15,6 +15,7 @@
| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` |
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` |
| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | | 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 |
| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 |
| 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` |
## 修改与验证 ## 修改与验证
+2
View File
@@ -24,5 +24,7 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies> </dependencies>
</project> </project>
@@ -0,0 +1,59 @@
package com.baiyue.common.core.outbox;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.IntSupplier;
/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */
public final class EventDeliveryJournal {
public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED }
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
this.jdbc = Objects.requireNonNull(jdbc);
if (!(manager instanceof DataSourceTransactionManager local)
|| local.getDataSource() != jdbc.getDataSource()) {
throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager");
}
transaction = new TransactionTemplate(manager);
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
public boolean begin(String attempt, String service, String eventType, String entryId,
String eventId, String target, LocalDateTime now, BooleanSupplier claim) {
return Boolean.TRUE.equals(transaction.execute(status -> {
if (!claim.getAsBoolean()) return false;
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')",
attempt, service, eventType, entryId, eventId, target, now);
return true;
}));
}
public boolean complete(String attempt, Outcome outcome, LocalDateTime now,
String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) {
if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode)
&& !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code");
return Boolean.TRUE.equals(transaction.execute(status -> {
int changed = updateOwnedOutbox.getAsInt();
if (changed == 0) return false; // A stale worker has no right to change either record.
if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count");
LocalDateTime started = jdbc.queryForObject(
"SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE",
LocalDateTime.class, attempt);
long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis());
int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'",
now, duration, outcome.name(), errorCode, nextRetry, attempt);
if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing");
return true;
}));
}
}
@@ -0,0 +1,4 @@
package com.baiyue.common.core.outbox;
/** Marks services that require durable attempt audit and cached delivery metrics. */
public interface EventDeliveryProducer { }
@@ -0,0 +1,20 @@
package com.baiyue.common.core.outbox;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Dedicated operations credential; a missing or weak configuration disables this capability. */
public final class EventOperationsTokenGuard {
private final byte[] expected;
public EventOperationsTokenGuard(String token) {
expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
}
public void require(String supplied) {
if (expected.length < 32 || supplied == null || supplied.length() > 4096
|| !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用");
}
}
}
@@ -0,0 +1,129 @@
package com.baiyue.common.core.outbox;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */
public final class OutboxOperations {
public record Layout(String table, String idColumn, String statusColumn, Set<String> eventTypes) {
public Layout {
for (String name : List.of(table, idColumn, statusColumn)) {
if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier");
}
eventTypes = Set.copyOf(eventTypes);
if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required");
}
}
public record Identity(String eventId, String eventType, String payloadHash) { }
public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { }
public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { }
public record Entry(String entryId, String eventId, String eventType, String payloadHash,
String status, int failures, String lastError, Object nextRetryAt) { }
private final JdbcTemplate jdbc;
private final Layout layout;
private final Function<String, Identity> identityReader;
private final TransactionTemplate transaction;
public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout,
Function<String, Identity> identityReader) {
this.jdbc = jdbc;
this.layout = layout;
this.identityReader = identityReader;
this.transaction = new TransactionTemplate(transactions);
}
public Entry inspect(String entryId) {
validateId(entryId);
Map<String, Object> row = find(entryId, false);
Identity identity = identity(row);
return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(),
String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(),
(String) row.get("last_error"), row.get("next_retry_at"));
}
public RetryResult retry(String entryId, RetryRequest request) {
validateId(entryId);
validateRequest(request);
try {
return transaction.execute(status -> {
Map<String, Object> row = find(entryId, true);
String canonicalId = String.valueOf(row.get("entry_id"));
Identity identity = identity(row);
if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) {
throw conflict("事件摘要已变化,拒绝重试");
}
List<Map<String, Object>> prior = jdbc.queryForList(
"SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?",
request.requestId());
if (!prior.isEmpty()) {
Map<String, Object> audit = prior.get(0);
if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id"))
|| !identity.eventId().equals(audit.get("event_id"))
|| !identity.payloadHash().equals(audit.get("payload_hash"))
|| !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) {
throw conflict("操作请求编号已用于不同重试参数");
}
return result(canonicalId, identity, request, true);
}
if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件");
LocalDateTime now = LocalDateTime.now();
int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn()
+ "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE "
+ layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId);
if (updated != 1) throw conflict("事件状态已变化");
jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(),
request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now);
return result(canonicalId, identity, request, false);
});
} catch (DuplicateKeyException duplicate) {
throw conflict("操作请求编号冲突,原事务已回滚");
}
}
private Map<String, Object> find(String id, boolean lock) {
List<Map<String, Object>> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn()
+ " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE "
+ layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在");
return rows.get(0);
}
private Identity identity(Map<String, Object> row) {
Identity identity = identityReader.apply(String.valueOf(row.get("payload_json")));
if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口");
return identity;
}
private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) {
return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate);
}
private static void validateId(String entryId) {
if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效");
}
private static void validateRequest(RetryRequest request) {
if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空");
try {
if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException();
} catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); }
requireText(request.operator(), 128, "操作人");
requireText(request.reason(), 500, "原因");
if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) {
throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要");
}
}
private static void requireText(String value, int length, String label) {
if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) {
throw new BizException(ResultCode.BAD_REQUEST, label + "无效");
}
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -0,0 +1,83 @@
package com.baiyue.common.core;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class EventDeliveryJournalTest {
JdbcTemplate jdbc;
EventDeliveryJournal journal;
DataSourceTransactionManager transactions;
@BeforeEach void setup() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
transactions = new DataSourceTransactionManager(source);
journal = new EventDeliveryJournal(jdbc, transactions);
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))");
jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)");
}
boolean begin(String owner) {
return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(),
() -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1);
}
int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); }
String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); }
String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); }
@Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() {
new TransactionTemplate(transactions).executeWithoutResult(status -> {
assertThat(begin("a")).isTrue();
// Another connection sees STARTED before any HTTP would be invoked.
var other = new JdbcTemplate(jdbc.getDataSource());
assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
status.setRollbackOnly();
});
assertThat(state()).isEqualTo("PROCESSING");
assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence
assertThat(begin("b")).isFalse();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1);
}
@Test void startAuditFailureRollsBackTheClaim() {
assertThat(begin("a")).isTrue();
jdbc.update("UPDATE work SET state='PENDING',owner=NULL");
assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PENDING");
}
@Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() {
begin("a");
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PROCESSING");
assertThat(outcome("a")).isEqualTo("STARTED");
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue();
assertThat(state()).isEqualTo("PUBLISHED");
assertThat(outcome("a")).isEqualTo("APPLIED");
assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative();
}
@Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() {
begin("a");
var retryAt = LocalDateTime.now().plusSeconds(5);
assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt,
() -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue();
begin("b");
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse();
assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED");
assertThat(outcome("b")).isEqualTo("STARTED");
assertThat(state()).isEqualTo("PROCESSING");
}
@Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() {
jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'");
assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PROCESSING");
}
}
@@ -0,0 +1,23 @@
package com.baiyue.common.core;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class EventOperationsTokenGuardTest {
@Test void missingOrShortConfigurationDisablesOperations() {
for (String configured : new String[] {null, "", "short"}) {
var guard = new EventOperationsTokenGuard(configured);
assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class);
}
}
@Test void onlyExactCredentialIsAccepted() {
String token = "operation-secret-".repeat(3);
var guard = new EventOperationsTokenGuard(token);
assertThatCode(() -> guard.require(token)).doesNotThrowAnyException();
for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) {
assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class);
}
}
}
@@ -0,0 +1,88 @@
package com.baiyue.common.core;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.outbox.OutboxOperations;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.*;
class OutboxOperationsTest {
private static final String HASH = "sha256:" + "a".repeat(64);
private JdbcTemplate jdbc;
private OutboxOperations operations;
@BeforeEach void setup() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)");
operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source),
new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")),
payload -> new OutboxOperations.Identity(payload, "business.completed", HASH));
jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)");
}
private OutboxOperations.RetryRequest request() {
return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH);
}
@Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() {
var request = request();
assertThat(operations.retry("1", request).duplicate()).isFalse();
assertThat(operations.retry("1", request).duplicate()).isTrue();
assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'"))
.containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING")
.containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null);
assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit"))
.containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout")
.containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH);
}
@Test void auditInsertFailureRollsBackRequeue() {
jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)");
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertThat(operations.inspect("1").status()).isEqualTo("DEAD");
assertThat(operations.inspect("1").failures()).isEqualTo(12);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero();
}
@Test void staleHashAndNonDeadAndReusedRequestAreRejected() {
var original = request();
var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64));
assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class);
operations.retry("1", original);
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class);
var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH);
assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
}
@Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception {
var request = request();
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(2);
try {
var first = pool.submit(() -> { start.await(); return operations.retry("1", request); });
var second = pool.submit(() -> { start.await(); return operations.retry("1", request); });
start.countDown();
assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
} finally { pool.shutdownNow(); }
}
@Test void crossEntryRequestCollisionRollsBackSecondEntry() {
jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)");
var request = request();
operations.retry("1", request);
assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class);
assertThat(operations.inspect("2").status()).isEqualTo("DEAD");
}
}
+2 -2
View File
@@ -20,7 +20,7 @@
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 |
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
@@ -29,7 +29,7 @@
## 修改与验证 ## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 - `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import java.time.Instant; import java.time.Instant;
/** /**
* 跨服务和跨区域传输使用的不可变事件信封。 * 统一测试环境中跨业务服务传输使用的不可变事件信封。
* *
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 * <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p> * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
@@ -14,8 +14,6 @@ import java.time.Instant;
* @param eventType 事件类型,不包含版本号 * @param eventType 事件类型,不包含版本号
* @param eventVersion 事件契约版本,从 1 开始 * @param eventVersion 事件契约版本,从 1 开始
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
* @param sourceRegion 事件来源业务单元
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
* @param tenantId 事件所属客户租户 ID * @param tenantId 事件所属客户租户 ID
* @param aggregateType 聚合类型 * @param aggregateType 聚合类型
* @param aggregateId 聚合 ID * @param aggregateId 聚合 ID
@@ -32,8 +30,6 @@ public record EventEnvelope(
String eventType, String eventType,
int eventVersion, int eventVersion,
Instant occurredAt, Instant occurredAt,
RegionCode sourceRegion,
RegionCode targetRegion,
String tenantId, String tenantId,
String aggregateType, String aggregateType,
String aggregateId, String aggregateId,
@@ -37,12 +37,6 @@ public final class EventEnvelopeValidator {
if (envelope.occurredAt() == null) { if (envelope.occurredAt() == null) {
errors.add("occurredAt 不能为空"); errors.add("occurredAt 不能为空");
} }
if (envelope.sourceRegion() == null) {
errors.add("sourceRegion 不能为空");
}
if (envelope.targetRegion() == null) {
errors.add("targetRegion 不能为空");
}
requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH); requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
if (envelope.aggregateType() == null if (envelope.aggregateType() == null
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
@@ -1,13 +0,0 @@
package com.baiyue.common.event;
/**
* 事件来源与目标部署区域。
*
* <p>区域表示独立业务单元,不表示用户当前所在地。</p>
*/
public enum RegionCode {
/** 国内业务单元。 */
CN,
/** 海外业务单元。 */
OVERSEAS
}
@@ -8,8 +8,7 @@ import static org.assertj.core.api.Assertions.*;
class DeliveryAckTest { class DeliveryAckTest {
private EventEnvelope event(String type) { private EventEnvelope event(String type) {
return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), "0", "TEST", "1", 1, null,
RegionCode.CN, RegionCode.OVERSEAS, "0", "TEST", "1", 1, null,
null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64));
} }
@@ -13,7 +13,7 @@ import java.util.List;
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.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("跨区域事件信封契约") @DisplayName("跨服务事件信封契约")
class EventEnvelopeTest { class EventEnvelopeTest {
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
@@ -26,6 +26,8 @@ class EventEnvelopeTest {
EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class); EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class);
assertThat(restored).isEqualTo(source); assertThat(restored).isEqualTo(source);
assertThat(objectMapper.readTree(json).has("sourceRegion")).isFalse();
assertThat(objectMapper.readTree(json).has("targetRegion")).isFalse();
assertThat(objectMapper.readTree(json).path("eventType").asText()) assertThat(objectMapper.readTree(json).path("eventType").asText())
.isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED); .isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED);
} }
@@ -40,7 +42,7 @@ class EventEnvelopeTest {
@Test @Test
void validatorReturnsAllBasicErrorsAtOnce() { void validatorReturnsAllBasicErrorsAtOnce() {
EventEnvelope envelope = new EventEnvelope( EventEnvelope envelope = new EventEnvelope(
"bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", "bad-id", "bad_type", 0, null, " ", "bad-type", " ",
-1, 0L, " ", " ", "bad-cause", null, "bad-hash"); -1, 0L, " ", " ", "bad-cause", null, "bad-hash");
List<String> errors = EventEnvelopeValidator.validate(envelope); List<String> errors = EventEnvelopeValidator.validate(envelope);
@@ -90,8 +92,6 @@ class EventEnvelopeTest {
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
1, 1,
Instant.parse("2026-07-29T08:30:00Z"), Instant.parse("2026-07-29T08:30:00Z"),
RegionCode.CN,
RegionCode.OVERSEAS,
"20001", "20001",
"FIRST_LEG_SHIPMENT", "FIRST_LEG_SHIPMENT",
"90001", "90001",
+3 -2
View File
@@ -13,7 +13,7 @@
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 |
| 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` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
@@ -24,5 +24,6 @@
- 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。 - `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。
- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。 - 从父目录运行 `mvn -pl common-security -am test`。
@@ -50,13 +50,16 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis, org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) { @Value("${baiyue.security.session-mode:redis}") String sessionMode,
@Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
if (!"redis".equalsIgnoreCase(sessionMode)) { if (!"redis".equalsIgnoreCase(sessionMode)) {
throw new IllegalStateException("Only shared Redis access sessions are supported"); throw new IllegalStateException("Only shared Redis access sessions are supported");
} }
StringRedisTemplate template = redis.getIfAvailable(); StringRedisTemplate template = redis.getIfAvailable();
if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate");
AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper); AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper,
new HttpSessionStateVerifier(validationUri, internalToken, objectMapper));
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
} }
@@ -0,0 +1,44 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */
public final class HttpSessionStateVerifier implements SessionStateVerifier {
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1))
.followRedirects(HttpClient.Redirect.NEVER).build();
private final URI uri;
private final String token;
private final ObjectMapper mapper;
public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) {
this.uri = URI.create(uri);
this.token = token;
this.mapper = mapper;
}
@Override public void verify(String jwtId, Long userId) {
try {
var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4))
.header("Content-Type", "application/json").header("X-Internal-Service-Token", token)
.POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(
Map.of("jwtId", jwtId, "userId", userId)))).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var body = mapper.readTree(response.body());
if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText())
|| !body.path("data").isBoolean() || !body.path("data").booleanValue()) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException("Authorization state validation interrupted", failure);
} catch (Exception failure) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure);
}
}
}
@@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
private final String keyPrefix; private final String keyPrefix;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { private final SessionStateVerifier stateVerifier;
this(redisTemplate, keyPrefix, new ObjectMapper());
}
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
ObjectMapper objectMapper) { ObjectMapper objectMapper, SessionStateVerifier stateVerifier) {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier);
} }
@Override @Override
@@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
if (userId == null || !userId.equals(session.userId())) { if (userId == null || !userId.equals(session.userId())) {
throw new IllegalArgumentException("Access token session identity mismatch"); throw new IllegalArgumentException("Access token session identity mismatch");
} }
stateVerifier.verify(jwtId, userId);
return session; return session;
} catch (org.springframework.dao.DataAccessException exception) { } catch (org.springframework.dao.DataAccessException exception) {
throw new IllegalArgumentException("Token session store is unavailable", exception); throw new IllegalArgumentException("Token session store is unavailable", exception);
@@ -0,0 +1,7 @@
package com.baiyue.common.security;
/** Verifies persisted authorization state, without positive caching. */
@FunctionalInterface
public interface SessionStateVerifier {
void verify(String jwtId, Long userId);
}
@@ -0,0 +1,45 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
class HttpSessionStateVerifierTest {
@Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception {
var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}");
var auth = new AtomicReference<String>();
var internal = new AtomicReference<String>();
var input = new AtomicReference<String>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/validate", exchange -> {
auth.set(exchange.getRequestHeaders().getFirst("Authorization"));
internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
try {
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper());
verifier.verify("session", 42L);
assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only");
assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42);
for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}",
"{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) {
body.set(rejected);
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
} finally { server.stop(0); }
}
@Test void unavailableAuthorityFailsClosed() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
int port = server.getAddress().getPort(); server.start(); server.stop(0);
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper());
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -56,7 +56,7 @@ class JwtAuthenticationFilterTest {
.thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}");
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(),
new JsonAuthenticationEntryPoint(new ObjectMapper()), new JsonAuthenticationEntryPoint(new ObjectMapper()),
new RedisAccessTokenSessionValidator(redis, "baiyue:auth")); new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class)));
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/platform/v1/last-leg/orders"); request.setRequestURI("/api/platform/v1/last-leg/orders");
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
@@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1")) when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}");
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))).doesNotThrowAnyException(); .validate(jwt("jti-1"))).doesNotThrowAnyException();
} }
@@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest {
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); when(values.get("baiyue:auth:access:jti-1")).thenReturn(null);
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))) .validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("revoked or expired"); .hasMessageContaining("revoked or expired");
@@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1")) when(values.get("baiyue:auth:access:jti-1"))
.thenThrow(new RedisConnectionFailureException("offline")); .thenThrow(new RedisConnectionFailureException("offline"));
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))) .validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unavailable"); .hasMessageContaining("unavailable");
@@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1")) when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))) .validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("identity mismatch"); .hasMessageContaining("identity mismatch");
@@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest {
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))) .validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid access token session"); .hasMessageContaining("Invalid access token session");
@@ -15,7 +15,7 @@ class UnifiedSessionModeTest {
for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) {
assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null,
new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()),
redis, new ObjectMapper(), "baiyue:auth", mode)) redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token"))
.isInstanceOf(IllegalStateException.class); .isInstanceOf(IllegalStateException.class);
} }
} }
+2
View File
@@ -14,6 +14,8 @@
<name>baiyue-common-web</name> <name>baiyue-common-web</name>
<dependencies> <dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
<dependency> <dependency>
<groupId>com.baiyue.shared</groupId> <groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-core</artifactId> <artifactId>baiyue-common-core</artifactId>
@@ -3,6 +3,13 @@ package com.baiyue.common.web;
import feign.Request; import feign.Request;
import feign.Capability; import feign.Capability;
import feign.Retryer; import feign.Retryer;
import feign.codec.Decoder;
import feign.codec.DecodeException;
import com.baiyue.common.core.api.ApiResponse;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.reflect.ParameterizedType;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -22,6 +29,9 @@ public class EventDeliveryFeignConfiguration {
} }
/** Applied at Feign build time after global/client properties, without changing other clients. */ /** Applied at Feign build time after global/client properties, without changing other clients. */
public static final class DeliveryTransportCapability implements Capability { public static final class DeliveryTransportCapability implements Capability {
private static final ObjectMapper RESPONSE_JSON = new ObjectMapper()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private final Request.Options options; private final Request.Options options;
public DeliveryTransportCapability(Request.Options options) { this.options = options; } public DeliveryTransportCapability(Request.Options options) { this.options = options; }
@Override public Request.Options enrich(Request.Options ignored) { return options; } @Override public Request.Options enrich(Request.Options ignored) { return options; }
@@ -29,5 +39,26 @@ public class EventDeliveryFeignConfiguration {
// The persisted Outbox owns the retry budget. // The persisted Outbox owns the retry budget.
return Retryer.NEVER_RETRY; return Retryer.NEVER_RETRY;
} }
@Override public Decoder enrich(Decoder decoder) {
return (response, type) -> {
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) {
// Jackson normally coerces a numeric 200 into String. Inspect the wire value first.
if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request());
byte[] bytes;
try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); }
if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request());
try {
var root = RESPONSE_JSON.readTree(bytes);
if (root == null || !root.isObject() || !root.path("code").isTextual()) {
throw new IllegalArgumentException();
}
} catch (Exception invalid) {
throw new DecodeException(response.status(), "Invalid event response contract", response.request());
}
return decoder.decode(response.toBuilder().body(bytes).build(), type);
}
return decoder.decode(response, type);
};
}
} }
} }
@@ -0,0 +1,78 @@
package com.baiyue.common.web;
import io.micrometer.core.instrument.Gauge;
import com.baiyue.common.event.EventDeliveryRoute;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Fixed labels only. Scheduled samplers query the local schema; scrape callbacks only read memory. */
public final class EventDeliveryMetrics {
public enum Stream {
FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING",
List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")),
LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING",
List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")),
FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")),
FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted"));
public final String service, table, status, processing;
public final List<String> types;
Stream(String service, String table, String status, String processing, List<String> types) {
this.service = service; this.table = table; this.status = status; this.processing = processing; this.types = types;
}
public String type(String candidate) { return candidate != null && types.contains(candidate) ? candidate : "UNKNOWN"; }
public String target(String type) {
if (!types.contains(type)) return "UNRESOLVED"; // Audit only; never a transport fallback.
var route = EventDeliveryRoute.forEventType(type);
return route.serviceName() + route.path();
}
}
private record Snapshot(double pending, double dead, double oldestSeconds, double sampledAt, double success) { }
private static final class Cell { volatile Snapshot value = new Snapshot(Double.NaN, Double.NaN, Double.NaN, 0, 0); }
private final JdbcTemplate jdbc;
private final MeterRegistry registry;
private final Map<String, Cell> cells = new ConcurrentHashMap<>();
public EventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { this.jdbc = jdbc; this.registry = registry; }
public void sample(Stream stream, LocalDateTime now) {
for (String type : stream.types) {
Cell cell = cells.computeIfAbsent(stream.name() + ":" + type, ignored -> register(stream, type));
try {
String filter = stream == Stream.FINANCE ? "" : " WHERE event_type=?";
String sql = "SELECT SUM(CASE WHEN " + stream.status + " IN ('PENDING','" + stream.processing
+ "') THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN " + stream.status
+ "='DEAD' THEN 1 ELSE 0 END) AS dead_count, MIN(CASE WHEN " + stream.status
+ " IN ('PENDING','" + stream.processing + "') THEN created_at END) AS oldest FROM " + stream.table + filter;
Map<String, Object> result = stream == Stream.FINANCE ? jdbc.queryForMap(sql) : jdbc.queryForMap(sql, type);
LocalDateTime oldest = databaseTime(result.get("oldest"));
cell.value = new Snapshot(count(result.get("pending_count")), count(result.get("dead_count")),
oldest == null ? 0 : Math.max(0, Duration.between(oldest, now).toMillis() / 1000.0),
System.currentTimeMillis() / 1000.0, 1);
} catch (RuntimeException failure) {
Snapshot old = cell.value;
cell.value = new Snapshot(old.pending, old.dead, old.oldestSeconds, old.sampledAt, 0);
}
}
}
private Cell register(Stream stream, String type) {
Cell cell = new Cell();
Gauge.builder("baiyue.event.delivery.pending", cell, c -> c.value.pending).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.dead", cell, c -> c.value.dead).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.oldest.pending.seconds", cell, c -> c.value.oldestSeconds).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.sample.timestamp", cell, c -> c.value.sampledAt).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.sample.success", cell, c -> c.value.success).tags("service", stream.service, "event_type", type).register(registry);
return cell;
}
private static LocalDateTime databaseTime(Object value) {
if (value == null) return null;
if (value instanceof LocalDateTime local) return local;
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
throw new IllegalArgumentException("Unsupported Outbox timestamp type");
}
private static double count(Object value) { return value == null ? 0 : ((Number) value).doubleValue(); }
}
@@ -0,0 +1,29 @@
package com.baiyue.common.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.common.event.EventIdentifiers;
import feign.FeignException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
/** Diagnostic identity and finite audit error codes; never serialize exception messages or payloads. */
public final class EventDeliveryObservation {
private EventDeliveryObservation() { }
public static String eventId(ObjectMapper mapper, String json) {
try {
String id = mapper.readTree(json).path("eventId").asText("");
return EventIdentifiers.isSupported(id) ? id : null;
} catch (Exception invalid) { return null; } // Invalid payloads are still audited by Outbox entry ID.
}
public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) {
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) {
if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) {
return "HTTP_" + http.status();
}
}
if (!requestStarted) return "INVALID_EVENT";
return permanent ? "PERMANENT_FAILURE" : "DELIVERY_FAILURE";
}
}
@@ -0,0 +1,25 @@
package com.baiyue.common.web;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryProducer;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})
@ConditionalOnBean(EventDeliveryProducer.class)
public class EventDeliveryObservationAutoConfiguration {
@Bean
EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
return new EventDeliveryJournal(jdbc, manager);
}
@Bean
EventDeliveryMetrics eventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) {
return new EventDeliveryMetrics(jdbc, registry);
}
}
@@ -3,3 +3,4 @@ com.baiyue.common.web.CommonWebAutoConfiguration
com.baiyue.common.web.FeignTracePropagationAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
com.baiyue.common.web.BaiyueSchedulingAutoConfiguration com.baiyue.common.web.BaiyueSchedulingAutoConfiguration
com.baiyue.common.web.EventDeliveryObservationAutoConfiguration
@@ -11,6 +11,31 @@ import static org.assertj.core.api.Assertions.*;
class EventDeliveryFeignConfigurationTest { class EventDeliveryFeignConfigurationTest {
interface Endpoint { @RequestLine("POST /events") String send(); } interface Endpoint { @RequestLine("POST /events") String send(); }
interface TypedEndpoint { @RequestLine("POST /events") com.baiyue.common.core.api.ApiResponse<Map<String, Object>> send(); }
@Test void wireCodeMustBeAStringAndAmbiguousJsonCannotBeAcknowledged() {
for (String body : List.of("{\"code\":200}", "{\"code\":true}", "{\"code\":\"200\",\"code\":\"500\"}", "{\"code\":\"200\"} {}", "{}")) {
var decoded = new AtomicInteger();
TypedEndpoint endpoint = Feign.builder()
.decoder((response, type) -> { decoded.incrementAndGet(); return com.baiyue.common.core.api.ApiResponse.success(Map.of()); })
.addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60))
.client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build())
.target(TypedEndpoint.class, "http://event-consumer.invalid");
assertThatThrownBy(endpoint::send).isInstanceOf(feign.codec.DecodeException.class);
assertThat(decoded.get()).isZero();
}
}
@Test void validStringCodePreservesBodyForTheConfiguredDecoder() {
String body = "{\"code\":\"200\",\"data\":{\"outcome\":\"APPLIED\"}}";
TypedEndpoint endpoint = Feign.builder().decoder((response, type) -> {
assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)).isEqualTo(body);
return com.baiyue.common.core.api.ApiResponse.success(Map.of("outcome", "APPLIED"));
}).addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60))
.client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build())
.target(TypedEndpoint.class, "http://event-consumer.invalid");
assertThat(endpoint.send().data()).containsEntry("outcome", "APPLIED");
}
@Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() {
var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60);
@@ -0,0 +1,71 @@
package com.baiyue.common.web;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.LocalDateTime;
import java.sql.Timestamp;
import java.util.Map;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class EventDeliveryMetricsTest {
@Test void samplesBoundedTypesAndPreservesPriorValuesWhenDatabaseFailsWithoutQueryingDuringScrapes() {
var jdbc = spy(new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")));
jdbc.execute("CREATE TABLE ll_event_outbox(event_type VARCHAR(200),status VARCHAR(32),created_at TIMESTAMP)");
var now = LocalDateTime.now();
jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PENDING',?)", now.minusSeconds(60));
jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PROCESSING',?)", now.minusSeconds(30));
jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','DEAD',?)", now.minusSeconds(120));
jdbc.update("INSERT INTO ll_event_outbox VALUES('user-controlled-unknown-type','PENDING',?)", now.minusYears(1));
var registry = new SimpleMeterRegistry();
var metrics = new EventDeliveryMetrics(jdbc, registry);
metrics.sample(EventDeliveryMetrics.Stream.FEE, now);
assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2);
assertThat(registry.get("baiyue.event.delivery.dead").gauge().value()).isEqualTo(1);
assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isBetween(59.99, 60.01);
double sampledAt = registry.get("baiyue.event.delivery.sample.timestamp").gauge().value();
jdbc.execute("DROP TABLE ll_event_outbox");
metrics.sample(EventDeliveryMetrics.Stream.FEE, now.plusMinutes(1));
clearInvocations(jdbc);
assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2);
assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isEqualTo(sampledAt);
assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero();
registry.getMeters().forEach(meter -> {
assertThat(meter.getId().getTag("event_type")).isEqualTo("BusinessFeeSourceChanged");
meter.measure().forEach(measurement -> measurement.getValue());
});
verifyNoInteractions(jdbc);
}
@Test void supportsBothMysqlLocalDateTimeAndJdbcTimestampAndRejectsUnknownTypes() {
var jdbc = mock(JdbcTemplate.class);
var now = LocalDateTime.of(2026, 9, 29, 12, 0);
var registry = new SimpleMeterRegistry();
var metrics = new EventDeliveryMetrics(jdbc, registry);
for (Object oldest : List.of(now.minusSeconds(45), Timestamp.valueOf(now.minusSeconds(45)))) {
Map<String, Object> row = Map.of("pending_count", 1L, "dead_count", 0L, "oldest", oldest);
when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))).thenReturn(row);
metrics.sample(EventDeliveryMetrics.Stream.FEE, now);
assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45);
assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isEqualTo(1);
}
when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged")))
.thenReturn(Map.of("pending_count", 0L, "dead_count", 0L, "oldest", "not a JDBC time"));
metrics.sample(EventDeliveryMetrics.Stream.FEE, now);
assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(1);
assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45);
assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero();
}
@Test void initialFailureIsUnknownRatherThanFalseZero() {
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID(), "sa", ""));
var registry = new SimpleMeterRegistry();
new EventDeliveryMetrics(jdbc, registry).sample(EventDeliveryMetrics.Stream.FINANCE, LocalDateTime.now());
assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isNaN();
assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isZero();
assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero();
}
}
@@ -0,0 +1,53 @@
package com.baiyue.common.web;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryProducer;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import static org.assertj.core.api.Assertions.*;
class EventDeliveryObservationAutoConfigurationTest {
@Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() {
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", ""));
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class))
.withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { })
.withBean(JdbcTemplate.class, () -> jdbc)
.withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource()))
.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
.run(context -> {
assertThat(context).hasSingleBean(EventDeliveryJournal.class);
assertThat(context).hasSingleBean(EventDeliveryMetrics.class);
});
}
@Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class))
.withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", "")))
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean(EventDeliveryJournal.class);
assertThat(context).doesNotHaveBean(EventDeliveryMetrics.class);
});
}
@Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() {
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", ""));
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class))
.withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { })
.withBean(JdbcTemplate.class, () -> jdbc)
.withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource()))
.run(context -> assertThat(context).hasFailed());
}
@Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() {
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class))
.withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { })
.withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", "")))
.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
.run(context -> assertThat(context).hasFailed());
}
}
@@ -0,0 +1,22 @@
package com.baiyue.common.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class EventDeliveryObservationTest {
@Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() {
var mapper = new ObjectMapper();
for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) {
assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"" + id + "\",\"payload\":\"secret\"}")).isEqualTo(id);
}
assertThat(EventDeliveryObservation.eventId(mapper, "bad secret JSON")).isNull();
assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"Bearer secret\"}")).isNull();
}
@Test void arbitraryExceptionsAndRemoteMessagesCannotLeakIntoAttemptAudit() {
var failure = new IllegalStateException("payload={secret} Authorization=secret");
assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("DELIVERY_FAILURE");
assertThat(EventDeliveryObservation.errorCode(failure, true, true)).isEqualTo("PERMANENT_FAILURE");
assertThat(EventDeliveryObservation.errorCode(failure, true, false)).isEqualTo("INVALID_EVENT");
}
}
-5
View File
@@ -1,5 +0,0 @@
# 国内 Nacos 配置
将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。
尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。
-17
View File
@@ -1,17 +0,0 @@
spring:
datasource:
url: ${AUTH_DB_URL}
username: ${AUTH_DB_USERNAME}
password: ${AUTH_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
security:
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
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}
-15
View File
@@ -1,15 +0,0 @@
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:CN}
deployment-cell: ${DEPLOYMENT_CELL:prod-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}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events}
finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events}
-9
View File
@@ -1,9 +0,0 @@
spring:
datasource:
url: ${FINANCE_DB_URL}
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
-37
View File
@@ -1,37 +0,0 @@
# 头程本地 Nacos 配置
本目录配置用于本机开发环境,发布目标为 `public` 命名空间和 `BAIYUE` 分组。
配置通过环境变量保留覆盖能力,并提供当前本机可直接启动的默认地址。生产环境必须使用 `config/nacos/cn`,不得使用本目录中的本地密码和本地密钥。
发布命令:
```powershell
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1
```
也可以使用统一入口;`local` 会被强制限制在 `public` namespace:
```powershell
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local
```
配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。
本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括
`gateway-overseas.yml`、`lastleg-service.yml` 和
`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机
Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。
## MyBatis SQL 日志
本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。
可通过环境变量统一调整:
```powershell
$env:MYBATIS_SQL_LOG_LEVEL = "INFO" # 关闭 SQL 明细
$env:MYBATIS_SQL_LOG_LEVEL = "DEBUG" # 打开 SQL 明细
```
修改后重新发布本地 Nacos 配置并重启相关服务。SQL 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。
-5
View File
@@ -1,5 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUDIT_DB_USERNAME:root}
password: ${AUDIT_DB_PASSWORD:123456}
-15
View File
@@ -1,15 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456}
baiyue:
security:
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}
-73
View File
@@ -1,73 +0,0 @@
baiyue:
deployment:
region-code: ${REGION_CODE:CN}
cell: ${DEPLOYMENT_CELL:local-cn}
data-residency: ${DATA_RESIDENCY:CN}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
issuer: ${JWT_ISSUER:auth-service}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
warehouse:
public-tenant-id: 0
openapi.enabled: ${SWAGGER_ENABLED:true}
audit:
enabled: true
endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events
outbox:
enabled: ${AUDIT_OUTBOX_ENABLED:false}
publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false}
batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50}
lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60}
max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5}
max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2}
compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30}
retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30}
rocketmq:
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000}
spring:
jackson:
default-property-inclusion: non_null
deserialization.fail-on-unknown-properties: false
serialization.write-dates-as-timestamps: false
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos.discovery:
# Local services must stay reachable through the gateway when VPN adapters change priority.
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
openfeign:
circuitbreaker.enabled: true
client.config.default:
connect-timeout: 3000
read-timeout: 5000
server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
management:
endpoints.web.exposure.include: health,info,prometheus
endpoint.health:
probes.enabled: true
show-details: always
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:true}
swagger-ui.enabled: ${SWAGGER_ENABLED:true}
@@ -1,11 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
baiyue:
region: ${REGION_CODE:CN}
event-bridge:
accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true}
deployment-cell: ${DEPLOYMENT_CELL:local-cn}
data-residency: ${DATA_RESIDENCY:CN}
-123
View File
@@ -1,123 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FILE_DB_USERNAME:root}
password: ${FILE_DB_PASSWORD:123456}
baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
baiyue:
file:
export:
definitions:
CUSTOMER_LIST:
provider-service: iam-service
provider-path: /internal/exports/v1/CUSTOMER_LIST/batches
required-permission: platform:customer:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
-9
View File
@@ -1,9 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
-69
View File
@@ -1,69 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456}
spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false}
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:
lingxing:
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com}
provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01}
# Firstleg and the isolated Lingxing browser run on the same Docker network.
# Keep the environment override for deployments that use another endpoint.
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222}
username: ${LINGXING_USERNAME:}
password: ${LINGXING_PASSWORD:}
browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:}
request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s}
warehouses: [US01, US02]
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: 0
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
# 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。
signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
# 墨家使用平台级 AppKey;tenant-id 固定为 0,按全局箱码解析真实客户。
# warehouse-bindings 的键必须与设备上传的 machineNo 完全一致。
mojia:
enabled: ${MOJIA_MEASUREMENT_ENABLED:false}
secret: ${MOJIA_MEASUREMENT_SECRET:}
tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0}
subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0}
warehouse-bindings:
mojia-yiwu-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0}
# 历史通用应用配置,保留兼容;新接入请使用 mojia。
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0}
subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0}
-16
View File
@@ -1,16 +0,0 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:true}
swagger-ui.enabled: ${SWAGGER_ENABLED:true}
baiyue:
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
overseas-projection:
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
-11
View File
@@ -1,11 +0,0 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:true}
swagger-ui.enabled: ${SWAGGER_ENABLED:true}
baiyue:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
exposed-headers: ${CORS_EXPOSED_HEADERS:X-Request-Id,X-Trace-Id}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
-16
View File
@@ -1,16 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
baiyue:
security-event:
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}
@@ -1,53 +0,0 @@
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
integration:
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input}
output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output}
max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB}
max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000}
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
-29
View File
@@ -1,29 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${LASTLEG_DB_USERNAME:root}
password: ${LASTLEG_DB_PASSWORD:123456}
lastleg:
dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}
firstleg:
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: redis
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
-11
View File
@@ -1,11 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${ORDER_DB_USERNAME:root}
password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
-11
View File
@@ -1,11 +0,0 @@
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
@@ -1,19 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:123456}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
internal-service:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: redis
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}
-5
View File
@@ -1,5 +0,0 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:123456}
-19
View File
@@ -1,19 +0,0 @@
# 海外 Nacos 配置
本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
发布示例:
```powershell
# 开发环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment development -Region overseas -ServerAddr <dev-nacos> -Namespace <dev-overseas-namespace>
# 测试环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment test -Region overseas -ServerAddr <test-nacos> -Namespace <test-overseas-namespace>
```
远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。
-6
View File
@@ -1,6 +0,0 @@
spring:
datasource:
url: ${AUDIT_DB_URL}
username: ${AUDIT_DB_USERNAME}
password: ${AUDIT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
-67
View File
@@ -1,67 +0,0 @@
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:
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
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}
@@ -1,21 +0,0 @@
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}
-13
View File
@@ -1,13 +0,0 @@
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:}
@@ -1,9 +0,0 @@
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}
@@ -1,12 +0,0 @@
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:}
-6
View File
@@ -1,6 +0,0 @@
spring:
datasource:
url: ${PRINT_DB_URL}
username: ${PRINT_DB_USERNAME}
password: ${PRINT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+9
View File
@@ -0,0 +1,9 @@
# TEST 单机配置
唯一导入目录为本目录的 14 个 YAML:`baiyue-common.yml` 加 13 个服务 Data ID。Group 默认 `BAIYUE`,所有配置和发现共用实际 `NACOS_NAMESPACE` ID;不能填写命名空间显示名。先导入公共配置再导入服务配置。应用不再使用 optional import,缺失配置应阻断启动。
变量在服务进程内解析,导入文件不得替换成真实密钥。无密钥模板见 `../../../deploy/env/.env.test.example`。私钥仅传入 Auth;运维令牌仅传入 Firstleg、Finance、Lastleg。各业务数据库 Schema 独立。
离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '<explicit-address>' -Namespace '<actual-id>' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。
切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。
+12
View File
@@ -0,0 +1,12 @@
spring:
datasource:
url: ${AUTH_DB_URL}
username: ${AUTH_DB_USERNAME}
password: ${AUTH_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
security:
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
baiyue.security.jwt.rsa-jwk-set: ${JWT_RSA_JWK_SET}
baiyue.security.jwt.current-kid: ${JWT_CURRENT_KID}
@@ -1,10 +1,9 @@
baiyue: baiyue:
deployment:
region-code: ${REGION_CODE:CN}
cell: ${DEPLOYMENT_CELL:prod-cn}
data-residency: ${DATA_RESIDENCY:CN}
security: security:
jwt-secret: ${JWT_SECRET} jwt-mode: jwks
session-mode: redis
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
internal-service-token: ${INTERNAL_SERVICE_TOKEN} internal-service-token: ${INTERNAL_SERVICE_TOKEN}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
@@ -24,7 +23,7 @@ baiyue:
compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30}
retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30}
rocketmq: rocketmq:
topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1 topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000}
spring: spring:
jackson: jackson:
@@ -35,7 +34,8 @@ spring:
write-dates-as-timestamps: false write-dates-as-timestamps: false
data: data:
redis: redis:
host: ${REDIS_HOST:redis-cn.internal} username: ${REDIS_USERNAME:default}
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0} database: ${REDIS_DATABASE:0}
@@ -44,7 +44,7 @@ spring:
nacos: nacos:
discovery: discovery:
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:prod-cn} namespace: ${NACOS_NAMESPACE}
openfeign: openfeign:
circuitbreaker.enabled: true circuitbreaker.enabled: true
client.config.default: client.config.default:
@@ -76,3 +76,5 @@ mybatis-plus:
springdoc: springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false} api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false}
baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
+18
View File
@@ -0,0 +1,18 @@
spring:
datasource:
url: ${FINANCE_DB_URL}
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
baiyue.event-delivery.observation-enabled: true
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000}
baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60}
baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12}
baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5}
baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800}
baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1}

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