优化
This commit is contained in:
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-131
@@ -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"
|
deleteDir()
|
||||||
String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
unstash 'deployment-files'
|
||||||
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
|
||||||
// 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()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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验收。
|
||||||
|
|||||||
@@ -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
@@ -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。
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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
|
||||||
-28
@@ -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");
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|||||||
@@ -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 { }
|
||||||
+20
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
-6
@@ -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",
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
+5
-2
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -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);
|
||||||
|
}
|
||||||
+45
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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");
|
||||||
|
|||||||
+5
-5
@@ -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");
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -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
|
||||||
|
|||||||
+25
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 国内 Nacos 配置
|
|
||||||
|
|
||||||
将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。
|
|
||||||
|
|
||||||
尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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:}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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 提供;不要把真实值写入本目录。
|
|
||||||
@@ -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
|
|
||||||
@@ -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}
|
|
||||||
@@ -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:}
|
|
||||||
@@ -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
|
|
||||||
@@ -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`;文件存在不代表环境已发布。
|
||||||
@@ -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}
|
||||||
@@ -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
Reference in New Issue
Block a user