Compare commits
252
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86fab838a | ||
|
|
2f1bda8369 | ||
|
|
a213be67c5 | ||
|
|
a4189a0074 | ||
|
|
a4429f95b7 | ||
|
|
5ddaa4d51f | ||
|
|
8a7c92bb0f | ||
|
|
4f2b670ebf | ||
|
|
66b9c3aa77 | ||
|
|
6a83008ba6 | ||
|
|
5f341d6fb5 | ||
|
|
8d39c61863 | ||
|
|
7e0911951d | ||
|
|
a79999d97c | ||
|
|
9b6e257f8d | ||
|
|
0103ea96c8 | ||
|
|
3975759458 | ||
|
|
e9b03a3d35 | ||
|
|
29c27d2fd7 | ||
|
|
cbcbb9c764 | ||
|
|
64047df5b3 | ||
|
|
aad8dd6344 | ||
|
|
9df87b17ac | ||
|
|
2280154773 | ||
|
|
05e3aa4e8d | ||
|
|
ef45325643 | ||
|
|
92d7c0a7b0 | ||
|
|
795e852944 | ||
|
|
6c4543c18c | ||
|
|
63182a2b88 | ||
|
|
b04c58a76a | ||
|
|
fd1a81594a | ||
|
|
5c6c0ef5ca | ||
|
|
57a2f6ef28 | ||
|
|
2ebea9e789 | ||
|
|
f2877df697 | ||
|
|
4ae236fe11 | ||
|
|
93e1540e3f | ||
|
|
3f9ee28d22 | ||
|
|
36534fe9ce | ||
|
|
1f98c5ab9c | ||
|
|
bf960f10d1 | ||
|
|
e31fd8caa2 | ||
|
|
0f4524c795 | ||
|
|
14126dce5b | ||
|
|
dc564c9530 | ||
|
|
7f05183904 | ||
|
|
4398e8b347 | ||
|
|
394c690601 | ||
|
|
fc0a6ea7ca | ||
|
|
1584c7a0a3 | ||
|
|
8eeb52ba77 | ||
|
|
9ea2aa46ec | ||
|
|
cf5d603a21 | ||
|
|
15421db421 | ||
|
|
270372f56a | ||
|
|
897ddd476f | ||
|
|
44fee01761 | ||
|
|
5d2b6c22e6 | ||
|
|
ead9565a37 | ||
|
|
3bf68c1083 | ||
|
|
6e55d94f33 | ||
|
|
7d36759f3f | ||
|
|
65c01f8b3d | ||
|
|
b6226f1ca6 | ||
|
|
ca69434698 | ||
|
|
335ad8269f | ||
|
|
7471f7e81f | ||
|
|
62407610cb | ||
|
|
0def6114ee | ||
|
|
25cb3bfa54 | ||
|
|
4e8c15dbfa | ||
|
|
3ab1a9d173 | ||
|
|
505d448850 | ||
|
|
2dcfdd5fc8 | ||
|
|
eb1a8895ac | ||
|
|
87c5e18ca6 | ||
|
|
1f6f1a0677 | ||
|
|
fa92936406 | ||
|
|
44198866d9 | ||
|
|
e815abcbd7 | ||
|
|
749a0d40a7 | ||
|
|
39988cbaa5 | ||
|
|
a067a9f056 | ||
|
|
f325d57fc1 | ||
|
|
5abc7543ae | ||
|
|
121e8ea619 | ||
|
|
7eca39e4fd | ||
|
|
f1af749616 | ||
|
|
5fb80d7624 | ||
|
|
9093550615 | ||
|
|
98766e2b1a | ||
|
|
72f3f9f971 | ||
|
|
0302985f21 | ||
|
|
6d8e4c85c5 | ||
|
|
2cfaf8e51b | ||
|
|
79eb3efe61 | ||
|
|
995c1833f8 | ||
|
|
d686b05966 | ||
|
|
4cca194eee | ||
|
|
1382902f86 | ||
|
|
12dad6673d | ||
|
|
4b5e5a1e4e | ||
|
|
0e0b6f8264 | ||
|
|
24df851acf | ||
|
|
d9c8cc478e | ||
|
|
322de03312 | ||
|
|
4fbf8bfb53 | ||
|
|
d64706e97b | ||
|
|
d066381ae2 | ||
|
|
6ba4537d3d | ||
|
|
68bdc3e6bb | ||
|
|
042d0b738a | ||
|
|
45a3b9adfd | ||
|
|
2a16c91c89 | ||
|
|
a6be840588 | ||
|
|
52b9fc382f | ||
|
|
cb78bc6b41 | ||
|
|
b67217332a | ||
|
|
b211121365 | ||
|
|
46dd0b54eb | ||
|
|
df4b7a1f6e | ||
|
|
29b2f080d8 | ||
|
|
4839243f21 | ||
|
|
c218bfad6b | ||
|
|
85a288e4f2 | ||
|
|
73d206e3f3 | ||
|
|
84ad5e779a | ||
|
|
24149c062a | ||
|
|
e94f8629d2 | ||
|
|
1c40548986 | ||
|
|
55a9d1af09 | ||
|
|
a22a284e7d | ||
|
|
058bf9f4e6 | ||
|
|
615ecb4ba1 | ||
|
|
985d952e54 | ||
|
|
bf4fd95841 | ||
|
|
1e8d85c714 | ||
|
|
0e73a79479 | ||
|
|
3fbb7653f7 | ||
|
|
8cc4c6f6da | ||
|
|
3607bebbfd | ||
|
|
015fc09091 | ||
|
|
005c3f54e2 | ||
|
|
5edc2fc1bd | ||
|
|
c93fdc44d5 | ||
|
|
9e0f41e78b | ||
|
|
a811c2ca06 | ||
|
|
ac806704be | ||
|
|
bffe7d3b23 | ||
|
|
1e4b216afa | ||
|
|
2f986cf5e3 | ||
|
|
89bd746dbd | ||
|
|
5c54785d77 | ||
|
|
aa3e72af28 | ||
|
|
551560efda | ||
|
|
7e080622f3 | ||
|
|
360115de35 | ||
|
|
111500d010 | ||
|
|
4f2abf2894 | ||
|
|
5c331f173d | ||
|
|
52e4b914b1 | ||
|
|
f4a1ec92de | ||
|
|
4893542ec4 | ||
|
|
633cec7c68 | ||
|
|
a320198b47 | ||
|
|
bb74a05562 | ||
|
|
be1eb4f45c | ||
|
|
9d09b77442 | ||
|
|
ce82916036 | ||
|
|
5a4bcf130a | ||
|
|
3d8a1fac20 | ||
|
|
01675b8220 | ||
|
|
132d96a99b | ||
|
|
8708904dab | ||
|
|
3019f5da77 | ||
|
|
8e12dd9a38 | ||
|
|
6aca41427c | ||
|
|
ef9f253f47 | ||
|
|
95b72afdcc | ||
|
|
e773816c98 | ||
|
|
e11a528dfe | ||
|
|
ce9f08c756 | ||
|
|
cd97229791 | ||
|
|
c88d99a593 | ||
|
|
a8326455fc | ||
|
|
2f32f1d827 | ||
|
|
e781cbe9cb | ||
|
|
8e61cfd202 | ||
|
|
506cc69808 | ||
|
|
f041cf524b | ||
|
|
23335ee8d0 | ||
|
|
98ec62f66e | ||
|
|
b012d64b54 | ||
|
|
9dd2fcb7e5 | ||
|
|
6a86dbcb37 | ||
|
|
6ddf0d823b | ||
|
|
b72dd54b80 | ||
|
|
92dbeb1676 | ||
|
|
4377a7acd9 | ||
|
|
2389a63858 | ||
|
|
ae3026dfdb | ||
|
|
f1639b9fe3 | ||
|
|
b3695276b3 | ||
|
|
739c20cb3d | ||
|
|
d59fccc731 | ||
|
|
4977b09af9 | ||
|
|
257c019b36 | ||
|
|
044678f65c | ||
|
|
287591c75f | ||
|
|
fe4eb0b593 | ||
|
|
9198ce02d9 | ||
|
|
a4a693837e | ||
|
|
c8574326dd | ||
|
|
938c5771b3 | ||
|
|
0fbba13714 | ||
|
|
ccfd024aef | ||
|
|
81899015cf | ||
|
|
ae9238b3cf | ||
|
|
657f3553d8 | ||
|
|
abab08e879 | ||
|
|
52b673b878 | ||
|
|
d3e16bf29f | ||
|
|
1df87e5d17 | ||
|
|
46e5c14b86 | ||
|
|
309900de82 | ||
|
|
232c2657f1 | ||
|
|
e398f1f7e0 | ||
|
|
e92cfe18ad | ||
|
|
996102e77d | ||
|
|
668668b505 | ||
|
|
fd1c582d45 | ||
|
|
0f296e016e | ||
|
|
a7ec6ed285 | ||
|
|
0d3a27f4ba | ||
|
|
dc131f26af | ||
|
|
775b2c4863 | ||
|
|
cee3ee07a8 | ||
|
|
5a78e9ca54 | ||
|
|
aa80ff4b2e | ||
|
|
7aebc3c0cb | ||
|
|
ba8a6b7832 | ||
|
|
ff9c1e2bc1 | ||
|
|
3b3d672fb1 | ||
|
|
b25087e60b | ||
|
|
85173b608d | ||
|
|
1f2c4cd8bb | ||
|
|
93feb7da67 | ||
|
|
76c3662dda | ||
|
|
af8724dd70 | ||
|
|
ab2f3ed72e | ||
|
|
15636a1600 |
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
node_modules/
|
||||
.docker-context/
|
||||
*.log
|
||||
.idea/
|
||||
|
||||
+57
-1
@@ -13,6 +13,55 @@ cache:
|
||||
paths:
|
||||
- .m2/repository/
|
||||
|
||||
Java拓扑合同:
|
||||
stage: verify
|
||||
image: node:22-alpine
|
||||
cache: []
|
||||
script:
|
||||
- node --test scripts/java-topology-contract.test.mjs
|
||||
|
||||
OpenAPI有效性:
|
||||
stage: verify
|
||||
image: node:22-alpine
|
||||
cache: []
|
||||
script:
|
||||
- npm ci --ignore-scripts --prefix scripts/openapi-validation
|
||||
- npm test --prefix scripts/openapi-validation
|
||||
- npm run validate --prefix scripts/openapi-validation
|
||||
|
||||
静态分析报告:
|
||||
stage: verify
|
||||
allow_failure: true
|
||||
script:
|
||||
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- "**/target/spotbugs*.xml"
|
||||
- "**/target/site/spotbugs.html"
|
||||
|
||||
质量摘要:
|
||||
stage: verify
|
||||
image: node:22-alpine
|
||||
cache: []
|
||||
allow_failure: true
|
||||
needs:
|
||||
- job: OpenAPI有效性
|
||||
artifacts: false
|
||||
- job: 校验与测试
|
||||
artifacts: true
|
||||
- job: 静态分析报告
|
||||
artifacts: true
|
||||
script:
|
||||
- node --test scripts/java-quality-summary.test.mjs
|
||||
- node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- target/java-quality-summary.json
|
||||
|
||||
校验与测试:
|
||||
stage: verify
|
||||
script:
|
||||
@@ -25,10 +74,14 @@ cache:
|
||||
- "**/target/surefire-reports/TEST-*.xml"
|
||||
paths:
|
||||
- "**/target/surefire-reports/"
|
||||
- "**/target/site/jacoco/"
|
||||
- "**/target/jacoco.exec"
|
||||
|
||||
打包服务:
|
||||
stage: package
|
||||
needs:
|
||||
- Java拓扑合同
|
||||
- OpenAPI有效性
|
||||
- 校验与测试
|
||||
script:
|
||||
- mvn $MAVEN_CLI_OPTS -DskipTests package
|
||||
@@ -36,14 +89,17 @@ cache:
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- gateway/target/*.jar
|
||||
- gateway-overseas/target/*.jar
|
||||
- auth/target/*.jar
|
||||
- iam/target/*.jar
|
||||
- order/target/*.jar
|
||||
- firstleg/target/*.jar
|
||||
- finance/target/*.jar
|
||||
- warehouse/target/*.jar
|
||||
- warehouse-overseas-service/target/*.jar
|
||||
- lastleg-service/target/*.jar
|
||||
- file/target/*.jar
|
||||
- print/target/*.jar
|
||||
- audit-service/target/*.jar
|
||||
- integration-service/target/*.jar
|
||||
- event-bridge-service/target/*.jar
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class DbQuery {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
|
||||
}
|
||||
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
|
||||
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
|
||||
+ "&useSSL=false&allowPublicKeyRetrieval=true";
|
||||
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
|
||||
Statement statement = connection.createStatement()) {
|
||||
boolean hasResult = statement.execute(args[1]);
|
||||
if (!hasResult) {
|
||||
System.out.println("updateCount=" + statement.getUpdateCount());
|
||||
return;
|
||||
}
|
||||
try (ResultSet result = statement.getResultSet()) {
|
||||
ResultSetMetaData meta = result.getMetaData();
|
||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||
if (column > 1) System.out.print("\t");
|
||||
System.out.print(meta.getColumnLabel(column));
|
||||
}
|
||||
System.out.println();
|
||||
while (result.next()) {
|
||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||
if (column > 1) System.out.print("\t");
|
||||
System.out.print(String.valueOf(result.getObject(column)));
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -11,7 +11,7 @@
|
||||
|
||||
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
||||
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
||||
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
||||
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
|
||||
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
||||
|
||||
## 服务路由(按关键词定位)
|
||||
@@ -20,10 +20,13 @@
|
||||
|---|---|---|
|
||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||
@@ -35,12 +38,16 @@
|
||||
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||
| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||
| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` |
|
||||
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` |
|
||||
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
|
||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
|
||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
|
||||
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
|
||||
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
|
||||
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
|
||||
|
||||
## 公共模块快速索引
|
||||
|
||||
@@ -48,7 +55,7 @@
|
||||
|---|---|
|
||||
| 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 |
|
||||
| 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 |
|
||||
| Web 异常、请求关联 ID、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` 定位 |
|
||||
| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 |
|
||||
| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
|
||||
| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
|
||||
| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
|
||||
@@ -74,9 +81,11 @@
|
||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||
|
||||
## 全量 HTTP 验收脚本路由
|
||||
|
||||
@@ -84,6 +93,7 @@
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||
|
||||
## 阶段文档入口
|
||||
|
||||
Vendored
+171
-41
@@ -21,25 +21,92 @@ def serviceCheckboxes = [
|
||||
'audit-service': 'SERVICE_AUDIT'
|
||||
]
|
||||
|
||||
def commonPaths = [
|
||||
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
|
||||
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
|
||||
'Jenkinsfile.service', 'deploy/'
|
||||
def fullRebuildPaths = [
|
||||
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
|
||||
'deploy/docker-compose.yml', 'deploy/service-map.yml'
|
||||
]
|
||||
|
||||
def commonPathServices = [
|
||||
'common-web/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
|
||||
'event-bridge-service', 'audit-service'
|
||||
],
|
||||
'common-security/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
|
||||
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
|
||||
],
|
||||
'common-openapi/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
|
||||
'event-bridge-service', 'audit-service'
|
||||
],
|
||||
'common-audit/': [
|
||||
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
|
||||
'warehouse-overseas-service', 'audit-service'
|
||||
],
|
||||
'common-event-contract/': [
|
||||
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
|
||||
],
|
||||
'deploy/docker-compose.overseas.yml': [
|
||||
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
|
||||
],
|
||||
'deploy/docker-compose.audit.yml': ['audit-service'],
|
||||
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
|
||||
]
|
||||
|
||||
def databaseMigrationPaths = ['deploy/sql/']
|
||||
|
||||
def downstreamResults = [:]
|
||||
|
||||
def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
String marker = ' | RESULTS'
|
||||
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
|
||||
int markerIndex = description.indexOf(marker)
|
||||
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
||||
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
||||
String summary = description.substring(markerIndex + marker.length()).trim()
|
||||
List<String> failedServices = []
|
||||
if (summary) {
|
||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||
int separator = item.indexOf('=')
|
||||
if (separator > 0) {
|
||||
String service = item.substring(0, separator).trim()
|
||||
String result = item.substring(separator + 1).trim()
|
||||
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
||||
}
|
||||
}
|
||||
}
|
||||
return [commit: commit, failedServices: failedServices.unique()]
|
||||
}
|
||||
|
||||
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
def candidate = previousBuild
|
||||
int inspected = 0
|
||||
while (candidate != null && inspected < 30) {
|
||||
def state = parseAutoBuildState(candidate.description, allowedServices)
|
||||
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
|
||||
candidate = candidate.previousBuild
|
||||
inspected++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
def environmentChoicesForJob(String jobBaseName) {
|
||||
return jobBaseName == 'backend-java-dev'
|
||||
? ['development', 'test', 'staging', 'production']
|
||||
: ['test', 'development', 'staging', 'production']
|
||||
}
|
||||
|
||||
def downstreamJobPrefixForJob(String jobBaseName) {
|
||||
return jobBaseName == 'backend-java-dev'
|
||||
? 'baiyue-firstleg-dev-'
|
||||
: 'baiyue-firstleg-'
|
||||
}
|
||||
|
||||
pipeline {
|
||||
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
|
||||
agent any
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
@@ -62,20 +129,20 @@ pipeline {
|
||||
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
|
||||
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
|
||||
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
|
||||
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)')
|
||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-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)')
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('确定构建范围') {
|
||||
agent { label 'java-builder' }
|
||||
steps {
|
||||
script {
|
||||
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
|
||||
@@ -86,19 +153,14 @@ pipeline {
|
||||
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
||||
|
||||
String requestedRepository
|
||||
if (['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
|
||||
requestedRepository = "baiyue-firstleg-${env.RESOLVED_ENVIRONMENT}"
|
||||
echo "${env.RESOLVED_ENVIRONMENT} 环境使用本地镜像,由构建节点直接传输到部署目标"
|
||||
} else {
|
||||
requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
|
||||
error('预发布/生产环境必须填写真实镜像仓库地址')
|
||||
error('必须填写真实镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
|
||||
}
|
||||
}
|
||||
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
|
||||
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
|
||||
|
||||
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
|
||||
@@ -107,7 +169,12 @@ pipeline {
|
||||
}
|
||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||
error('生产环境禁止跳过测试')
|
||||
}
|
||||
|
||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
List selected = []
|
||||
if (params.BUILD_MODE == 'ALL') {
|
||||
selected = serviceModules
|
||||
@@ -119,9 +186,17 @@ pipeline {
|
||||
error('SELECT 模式至少需要勾选一个服务')
|
||||
}
|
||||
} else {
|
||||
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
||||
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
|
||||
String base = autoState?.commit?.trim()
|
||||
List retryServices = autoState?.failedServices ?: []
|
||||
if (!base) {
|
||||
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
|
||||
// SELECT/ALL 不会推进 AUTO 基线。
|
||||
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
||||
}
|
||||
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
||||
selected = serviceModules
|
||||
echo 'AUTO 未找到可用基线,安全回退为全量构建'
|
||||
} else {
|
||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||
@@ -129,13 +204,27 @@ pipeline {
|
||||
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||
boolean commonChanged = changed.any { path ->
|
||||
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
List serviceRelevantChanges = changed.findAll { path ->
|
||||
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||
changed.any { it == module || it.startsWith("${module}/") }
|
||||
boolean fullRebuild = serviceRelevantChanges.any { path ->
|
||||
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
}
|
||||
if (fullRebuild) {
|
||||
selected = serviceModules
|
||||
} else {
|
||||
selected = serviceModules.findAll { module ->
|
||||
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
|
||||
}
|
||||
commonPathServices.each { prefix, impactedServices ->
|
||||
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
|
||||
selected.addAll(impactedServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.addAll(retryServices)
|
||||
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
|
||||
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +234,32 @@ pipeline {
|
||||
}
|
||||
|
||||
selected = selected.unique()
|
||||
if (params.DEPLOY && selected.contains('event-bridge-service')) {
|
||||
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||
}
|
||||
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
|
||||
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
|
||||
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||
}
|
||||
if (bridgeRegion == 'SELECT') {
|
||||
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
|
||||
}
|
||||
}
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS"
|
||||
: (env.DATABASE_CHANGE_DETECTED == 'true'
|
||||
? '仅数据库迁移变更 | 需单独审核执行'
|
||||
: '没有需要构建的业务服务'
|
||||
: '没有需要构建的业务服务')
|
||||
} else {
|
||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
|
||||
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||
@@ -176,14 +280,30 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('测试数据库初始化(可选)') {
|
||||
stage('统一 Maven 测试门禁') {
|
||||
when {
|
||||
allOf {
|
||||
expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
|
||||
}
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||
}
|
||||
agent { label 'java-builder' }
|
||||
tools { maven 'maven-3.8.8' }
|
||||
steps {
|
||||
sh 'sh deploy/scripts/initialize-test-databases.sh'
|
||||
script {
|
||||
String selectedModules = env.SELECTED_SERVICES.split(',')
|
||||
.collect { it.trim() }
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +316,14 @@ pipeline {
|
||||
String service = selectedService
|
||||
branches[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}"
|
||||
def downstreamBuild = build(
|
||||
job: jobName,
|
||||
@@ -203,13 +331,16 @@ pipeline {
|
||||
propagate: false,
|
||||
parameters: [
|
||||
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
|
||||
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
|
||||
string(name: 'IMAGE_TAG', value: downstreamImageTag),
|
||||
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
|
||||
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
|
||||
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
|
||||
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
|
||||
booleanParam(name: 'SKIP_TESTS', value: true),
|
||||
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
|
||||
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
|
||||
string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION)
|
||||
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
|
||||
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
|
||||
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
|
||||
]
|
||||
)
|
||||
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
|
||||
@@ -229,7 +360,9 @@ pipeline {
|
||||
.collect { service, result -> "${service}=${result}" }
|
||||
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
|
||||
echo "单服务执行汇总: ${summary}"
|
||||
currentBuild.description = "${env.IMAGE_TAG} | ${summary}"
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||
: "${env.IMAGE_TAG} | ${summary}"
|
||||
if (failed) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
@@ -238,7 +371,4 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always { cleanWs(deleteDirs: true, notFailBuild: true) }
|
||||
}
|
||||
}
|
||||
|
||||
+72
-49
@@ -38,18 +38,15 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入')
|
||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY_CREDENTIALS = 'gitea-test-token'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
|
||||
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
|
||||
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
|
||||
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
|
||||
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/application'
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -67,9 +64,7 @@ pipeline {
|
||||
}
|
||||
String sourceCommit = params.SOURCE_COMMIT?.trim()
|
||||
String imageTag = params.IMAGE_TAG?.trim()
|
||||
String imageRepository = ['development', 'test'].contains(params.ENVIRONMENT)
|
||||
? "baiyue-firstleg-${params.ENVIRONMENT}"
|
||||
: params.IMAGE_REPOSITORY?.trim()
|
||||
String imageRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
|
||||
if (!sourceCommit) {
|
||||
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
@@ -81,6 +76,11 @@ pipeline {
|
||||
if (!serviceModules.contains(service)) {
|
||||
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
||||
}
|
||||
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||
imageTag?.endsWith('-both')
|
||||
if (deployBothBridges) {
|
||||
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
|
||||
}
|
||||
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||||
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
||||
}
|
||||
@@ -90,14 +90,26 @@ pipeline {
|
||||
if (!imageRepository || imageRepository.contains('://')) {
|
||||
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
|
||||
}
|
||||
if (!['development', 'test'].contains(params.ENVIRONMENT) && !imageRepository.contains('/')) {
|
||||
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
if (!imageRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
}
|
||||
|
||||
env.RESOLVED_SERVICE = service
|
||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||
env.RESOLVED_IMAGE_TAG = imageTag
|
||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
|
||||
|
||||
if (service == 'event-bridge-service' && params.DEPLOY) {
|
||||
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||
}
|
||||
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
|
||||
!['development', 'test'].contains(params.ENVIRONMENT)) {
|
||||
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sh '''
|
||||
@@ -119,12 +131,15 @@ pipeline {
|
||||
stage('Maven 测试与打包') {
|
||||
steps {
|
||||
script {
|
||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||
error('生产环境禁止跳过测试')
|
||||
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
|
||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
|
||||
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
|
||||
}
|
||||
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||
if (params.ENVIRONMENT == 'test') {
|
||||
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
|
||||
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
|
||||
} else if (params.ENVIRONMENT == 'test') {
|
||||
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||
}
|
||||
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||
@@ -144,19 +159,21 @@ pipeline {
|
||||
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
|
||||
String jar = serviceJars[env.RESOLVED_SERVICE]
|
||||
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
||||
String dockerConfigDir = "${pwd(tmp: true)}/docker-auth"
|
||||
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
|
||||
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
|
||||
|
||||
if (['development', 'test'].contains(params.ENVIRONMENT)) {
|
||||
echo "${params.ENVIRONMENT} 镜像已保存在构建节点 ${env.NODE_NAME}: ${image}"
|
||||
} else {
|
||||
echo "推送共享镜像: ${image}"
|
||||
withEnv(["DOCKER_CONFIG=${dockerConfigDir}"]) {
|
||||
sh 'mkdir -p "$DOCKER_CONFIG"'
|
||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
||||
try {
|
||||
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
|
||||
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
|
||||
sh "docker push ${image}"
|
||||
} finally {
|
||||
sh "docker logout ${registryHost} || true"
|
||||
sh 'rm -rf "$DOCKER_CONFIG"'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.DEPLOY) {
|
||||
@@ -166,54 +183,60 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('直接部署开发或测试环境') {
|
||||
stage('部署开发或测试环境') {
|
||||
when {
|
||||
beforeAgent true
|
||||
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
|
||||
}
|
||||
agent none
|
||||
steps {
|
||||
script {
|
||||
boolean development = params.ENVIRONMENT == 'development'
|
||||
boolean testTargetIsLocal = ['master', 'built-in'].contains(env.NODE_NAME) ||
|
||||
['master', 'built-in'].any { label -> env.NODE_LABELS?.tokenize()?.contains(label) }
|
||||
boolean targetIsLocal = development
|
||||
? env.NODE_LABELS?.tokenize()?.contains('java-dev')
|
||||
: testTargetIsLocal
|
||||
echo "构建节点 ${env.NODE_NAME} 部署到 ${params.ENVIRONMENT},目标是否为本机: ${targetIsLocal}"
|
||||
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
|
||||
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
|
||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
||||
String remoteTarget = development ? 'deploy@172.31.198.8' : 'deploy@172.31.198.7'
|
||||
String remoteKey = development
|
||||
? '/var/jenkins_home/.ssh/dev_deploy'
|
||||
: '/home/deploy/.ssh/test_deploy'
|
||||
String remoteKnownHosts = development
|
||||
? '/var/jenkins_home/.ssh/known_hosts'
|
||||
: '/home/deploy/.ssh/known_hosts'
|
||||
String remoteDir = development
|
||||
? '/opt/firstleg/application'
|
||||
: '/opt/firstleg/test-application'
|
||||
|
||||
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
||||
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
||||
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
|
||||
lock(resource: serviceDeployResource, inversePrecedence: true) {
|
||||
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
|
||||
node(targetLabel) {
|
||||
deleteDir()
|
||||
try {
|
||||
unstash 'deployment-files'
|
||||
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.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=${targetIsLocal}",
|
||||
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}",
|
||||
"TARGET_SSH_TARGET=${remoteTarget}",
|
||||
"TARGET_SSH_KEY=${remoteKey}",
|
||||
"TARGET_SSH_KNOWN_HOSTS=${remoteKnownHosts}",
|
||||
"TARGET_DEPLOY_DIR=${remoteDir}"
|
||||
'TARGET_IS_LOCAL=true',
|
||||
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
|
||||
]) {
|
||||
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
|
||||
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
} finally {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署单个服务') {
|
||||
when {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 柏悦头程平台
|
||||
|
||||
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
||||
本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
|
||||
|
||||
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
||||
|
||||
@@ -12,20 +12,24 @@
|
||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||
- `warehouse`:国内仓储能力。
|
||||
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
|
||||
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
|
||||
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。
|
||||
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
|
||||
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
|
||||
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
|
||||
- `platform-bom`:统一管理仓库内公共依赖版本。
|
||||
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
mvn -DskipTests validate
|
||||
node --test scripts/java-topology-contract.test.mjs
|
||||
mvn test
|
||||
```
|
||||
|
||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||
|
||||
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
|
||||
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。
|
||||
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
|
||||
|
||||
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||
|
||||
@@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
|
||||
|
||||
## 仓库边界
|
||||
|
||||
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||
| 海外访问 Token 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 |
|
||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
|
||||
@@ -21,4 +21,12 @@ public class AuthorizationSnapshot {
|
||||
private Set<String> roles;
|
||||
private Set<String> permissions;
|
||||
private Long permissionVersion;
|
||||
private Long overseasWarehouseId;
|
||||
|
||||
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
|
||||
String clientType, Set<String> roles, Set<String> permissions,
|
||||
Long permissionVersion) {
|
||||
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
|
||||
permissionVersion, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@ public interface IamOnboardingClient {
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||
String dimensionInitializationStatus) { }
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
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.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||
public class InternalAccountController {
|
||||
@Resource
|
||||
private AccountManagementService service;
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private OverseasSessionValidationService overseasSessionValidationService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String configuredToken;
|
||||
@@ -64,6 +71,15 @@ public class InternalAccountController {
|
||||
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||
}
|
||||
@PostMapping("/overseas-session/validate")
|
||||
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
|
||||
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
|
||||
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(overseasSessionValidationService.validate(request));
|
||||
}
|
||||
@PutMapping("/{id}/profile")
|
||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||
|
||||
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
|
||||
Long tenantId,
|
||||
Long membershipId,
|
||||
String tenantStatus,
|
||||
String dimensionInitializationStatus,
|
||||
String nextStep
|
||||
) { }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
|
||||
public final class OverseasSessionValidationDtos {
|
||||
private OverseasSessionValidationDtos() { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationRequest",
|
||||
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
|
||||
public record Request(
|
||||
@NotNull Long userId,
|
||||
@NotNull Integer tokenVersion,
|
||||
Long overseasWarehouseId) { }
|
||||
|
||||
@Schema(name = "OverseasSessionValidationResponse")
|
||||
public record Response(boolean valid, FailureReason failureReason) {
|
||||
public static Response accepted() { return new Response(true, null); }
|
||||
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
ACCOUNT_INVALID,
|
||||
TOKEN_VERSION_MISMATCH,
|
||||
WAREHOUSE_UNBOUND,
|
||||
WAREHOUSE_MISMATCH
|
||||
}
|
||||
}
|
||||
@@ -111,12 +111,13 @@ public class AuthService {
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.subject(account.getId().toString())
|
||||
.claim("user_id", account.getId())
|
||||
.claim("username", account.getUsername())
|
||||
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
|
||||
.claim("tenant_id", account.getDefaultTenantId())
|
||||
.claim("operator_tenant_id", account.getOperatorTenantId())
|
||||
.claim("membership_id", account.getDefaultMembershipId())
|
||||
@@ -127,8 +128,11 @@ public class AuthService {
|
||||
.claim("userId", account.getId())
|
||||
.claim("tenantId", account.getDefaultTenantId())
|
||||
.claim("appCode", account.getDefaultAppCode())
|
||||
.claim("permissionVersion", account.getTokenVersion())
|
||||
.build();
|
||||
.claim("permissionVersion", account.getTokenVersion());
|
||||
if (authorization.getOverseasWarehouseId() != null) {
|
||||
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||
}
|
||||
JwtClaimsSet claims = claimsBuilder.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
signingHeader(), claims)).getTokenValue();
|
||||
|
||||
@@ -184,7 +188,7 @@ public class AuthService {
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
|
||||
.audience(List.of(properties.audience())).id(jwtId)
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
|
||||
.claim("user_id", account.getId()).claim("username", account.getUsername())
|
||||
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
|
||||
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
|
||||
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
|
||||
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
|
||||
|
||||
@@ -103,7 +103,8 @@ public class MerchantRegistrationService {
|
||||
throw exception;
|
||||
}
|
||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
|
||||
@Service
|
||||
public class OverseasSessionValidationService {
|
||||
@Resource private AuthAccountRepository accounts;
|
||||
@Resource private AuthorizationLoader authorizationLoader;
|
||||
|
||||
public Response validate(Request request) {
|
||||
AuthAccount account = accounts.findById(request.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||
return Response.rejected(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
|
||||
if (currentVersion != request.tokenVersion()) {
|
||||
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
|
||||
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
|
||||
}
|
||||
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
|
||||
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
return Response.accepted();
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public class OverseasTokenService {
|
||||
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
|
||||
Instant issuedAt = Instant.now();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
@@ -60,8 +60,12 @@ public class OverseasTokenService {
|
||||
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
|
||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
.build();
|
||||
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
|
||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
|
||||
if (authorization.getOverseasWarehouseId() != null) {
|
||||
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
|
||||
}
|
||||
JwtClaimsSet claims = claimsBuilder.build();
|
||||
String token = encoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Phase-one local administrator accounts.
|
||||
-- Phase-one local platform administrator account. Merchant accounts are created through onboarding.
|
||||
-- Development-only initial password: ChangeMe123!
|
||||
-- The BCrypt hash is intentionally stored instead of plaintext.
|
||||
USE baiyue_auth;
|
||||
@@ -10,9 +10,7 @@ INSERT INTO auth_account (
|
||||
operator_tenant_id, default_membership_id, default_app_code, deleted
|
||||
) VALUES
|
||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
|
||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
|
||||
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
password_hash = VALUES(password_hash),
|
||||
display_name = VALUES(display_name),
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasSessionValidationControllerContractTest {
|
||||
@Test
|
||||
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
|
||||
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
assertThat(method.getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/overseas-session/validate");
|
||||
assertThat(method.getAnnotation(Operation.class).description())
|
||||
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
|
||||
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
|
||||
.extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
|
||||
.doesNotContain("token", "accessToken", "authorization", "password");
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InitialAccountSeedSqlContractTest {
|
||||
@Test
|
||||
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
|
||||
void initializesOnlyThePlatformAdministratorAccount() throws Exception {
|
||||
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
||||
@@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest {
|
||||
assertThat(sql)
|
||||
.contains("(1000001, 'platform_admin'")
|
||||
.contains("'平台系统管理员', 'ACTIVE'")
|
||||
.contains("(1000002, 'merchant_admin'")
|
||||
.contains("'IT 测试客户管理员', 'ACTIVE'")
|
||||
.contains("password_change_required")
|
||||
.doesNotContain("merchant_admin")
|
||||
.doesNotContain("IT_TEST")
|
||||
.doesNotContain("(1000002, 'merchant_admin'")
|
||||
.doesNotContain("一期演示企业管理员");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -60,7 +63,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(1);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L);
|
||||
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(repository.findByUsername("operator")).thenReturn(account);
|
||||
@@ -82,12 +85,13 @@ class AuthServiceSmallJwtTest {
|
||||
.containsEntry("appCode", "PLATFORM")
|
||||
.containsEntry("permissionVersion", 1)
|
||||
.containsEntry("membership_id", 84L)
|
||||
.containsEntry("overseas_warehouse_id", 900L)
|
||||
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
@@ -123,12 +127,36 @@ class AuthServiceSmallJwtTest {
|
||||
var response = service.refresh("refresh-old");
|
||||
|
||||
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
|
||||
assertThat(response.tokenType()).isEqualTo("Bearer");
|
||||
assertThat(response.expiresIn()).isEqualTo(1800);
|
||||
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
|
||||
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
|
||||
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
|
||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||
verify(tokenSessionStore).consume("refresh-old");
|
||||
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
verify(authorizationLoader).load(account);
|
||||
verify(overseasTokenService).issue(account, snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
|
||||
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||
.hasMessageContaining("刷新令牌无效或已过期");
|
||||
|
||||
verify(repository, never()).findById(any());
|
||||
verify(jwtEncoder, never()).encode(any());
|
||||
verify(overseasTokenService, never()).issue(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
|
||||
@@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest {
|
||||
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||
|
||||
var result = service.register(new MerchantRegisterRequest(
|
||||
"registered_owner", "Password123", "注册人", "测试企业", "张三",
|
||||
@@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest {
|
||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
|
||||
|
||||
@@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest {
|
||||
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.AuthorizationLoader;
|
||||
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class OverseasSessionValidationServiceTest {
|
||||
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
||||
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
|
||||
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
|
||||
private AuthAccount account;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
inject("accounts", accounts);
|
||||
inject("authorizationLoader", authorizationLoader);
|
||||
account = new AuthAccount();
|
||||
account.setId(42L);
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setTokenVersion(8);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
|
||||
assertThat(service.validate(new Request(42L, 7, 900L)))
|
||||
.extracting("valid", "failureReason")
|
||||
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newlyIssuedTokenWithCurrentWarehousePasses() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrChangedWarehouseBindingFailsClosed() {
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
|
||||
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
|
||||
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
|
||||
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
|
||||
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAccountFailsClosedBeforeIamLookup() {
|
||||
account.setAccountStatus("DISABLED");
|
||||
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
|
||||
.isEqualTo(FailureReason.ACCOUNT_INVALID);
|
||||
}
|
||||
|
||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||
}
|
||||
|
||||
private void inject(String fieldName, Object value) throws Exception {
|
||||
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(service, value);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||
@@ -51,7 +52,7 @@ class OverseasTokenServiceTest {
|
||||
String lastlegAuthority = "lastleg:pda:operate";
|
||||
String domesticAuthority = "platform:firstleg:order:list";
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||
Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L);
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
@@ -71,9 +72,61 @@ class OverseasTokenServiceTest {
|
||||
.containsEntry("membership_id", 84L)
|
||||
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
||||
.containsEntry("permission_version", 7L)
|
||||
.containsEntry("roles", Set.of("PLATFORM_ADMIN"))
|
||||
.containsEntry("token_version", 0)
|
||||
.containsEntry("overseas_warehouse_id", 900L)
|
||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
||||
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
|
||||
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(8);
|
||||
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(first, second);
|
||||
when(first.getTokenValue()).thenReturn("overseas-old-binding");
|
||||
when(second.getTokenValue()).thenReturn("overseas-new-binding");
|
||||
|
||||
var oldToken = service.issue(account, oldBinding);
|
||||
var newToken = service.issue(account, newBinding);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder, times(2)).encode(captor.capture());
|
||||
var oldClaims = captor.getAllValues().get(0).getClaims();
|
||||
var newClaims = captor.getAllValues().get(1).getClaims();
|
||||
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
|
||||
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
|
||||
.containsEntry("permission_version", 8L);
|
||||
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
|
||||
.containsEntry("permission_version", 9L);
|
||||
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
|
||||
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||
|
||||
service.issue(account, unbound);
|
||||
|
||||
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||
verify(encoder).encode(captor.capture());
|
||||
assertThat(captor.getValue().getClaims().getClaims())
|
||||
.doesNotContainKey("overseas_warehouse_id");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||
| 契约说明 | `package-info.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ public final class EventTypes {
|
||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
||||
"FirstLegContainerShippingNodeRecorded";
|
||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
|
||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||
public record FirstLegContainerShippingNodeRecordedV1(
|
||||
String queryId,
|
||||
String customerId,
|
||||
String oceanBookingId,
|
||||
String containerBookingId,
|
||||
String containerNo,
|
||||
String standardNode,
|
||||
int nodeSequence,
|
||||
Instant observedAt,
|
||||
Instant actualAt,
|
||||
String locationCode,
|
||||
String locationName,
|
||||
String providerCode,
|
||||
String providerEventId,
|
||||
String providerNodeCode,
|
||||
String sourceReference,
|
||||
String rawPayloadHash) {
|
||||
private static final Map<String, Integer> STANDARD_NODES = Map.of(
|
||||
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
|
||||
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
|
||||
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
|
||||
"EMPTY_RETURNED", 100);
|
||||
|
||||
public FirstLegContainerShippingNodeRecordedV1 {
|
||||
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
||||
|| oceanBookingId == null || oceanBookingId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||
|| observedAt == null || actualAt == null) {
|
||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||
}
|
||||
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|
||||
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|
||||
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
|
||||
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
@@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-5
@@ -3,7 +3,7 @@ package com.baiyue.common.event;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
||||
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV2(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
@@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
@@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +62,7 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String shipmentNo, String shippingMark,
|
||||
String customerRemark, String internalRemark, String warehouseRemark,
|
||||
String palletNo,
|
||||
/** 头程箱唛二维码中的箱号;这些箱号均对应本明细的 skuCode。 */
|
||||
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
|
||||
List<String> cartonMarks) {
|
||||
public Item {
|
||||
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
@@ -71,7 +86,7 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键代发入仓单的托盘冻结快照。 */
|
||||
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
|
||||
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@ package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */
|
||||
/**
|
||||
* 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。
|
||||
* unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。
|
||||
*/
|
||||
public record OverseasContainerUnloadedV1(
|
||||
String sourceContainerBookingId,
|
||||
String containerNo,
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
|
||||
public record OverseasInboundOrderDetainedChangedV1(
|
||||
String sourceInboundId,
|
||||
String inboundCode,
|
||||
String lastlegOrderId,
|
||||
String lastlegOrderNo,
|
||||
boolean detained,
|
||||
String reason,
|
||||
String operatorId,
|
||||
String operatorName,
|
||||
Instant changedAt
|
||||
) {
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FirstLegContainerShippingNodeRecordedV1Test {
|
||||
@Test
|
||||
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
|
||||
var payload = payload();
|
||||
var mapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
var restored = mapper.readValue(mapper.writeValueAsString(payload),
|
||||
FirstLegContainerShippingNodeRecordedV1.class);
|
||||
|
||||
assertThat(restored).isEqualTo(payload);
|
||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||
return new FirstLegContainerShippingNodeRecordedV1(
|
||||
"2088000000000000001", "2088000000000000002", "2088000000000000004",
|
||||
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
||||
"ZIM:evt-1", "a".repeat(64));
|
||||
}
|
||||
}
|
||||
+13
@@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
assertThat(restored).isEqualTo(payload);
|
||||
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||
String json = """
|
||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
||||
""";
|
||||
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesFrozenDestinationType() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsEachOrdinaryCartonNumberToItsSku() {
|
||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
|
||||
|
||||
+2
@@ -20,5 +20,7 @@ class OverseasContainerUnloadedV1Test {
|
||||
|
||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
|
||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||
| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
|
||||
| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
|
||||
- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。
|
||||
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
|
||||
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
|
||||
- 从父目录运行 `mvn -pl common-openapi -am test`。
|
||||
|
||||
+8
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
|
||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||
return GroupedOpenApi.builder().group("pda")
|
||||
.displayName("PDA APIs / PDA 作业接口")
|
||||
.pathsToMatch("/api/pda/**")
|
||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
private Parameter header(String name, String description, boolean required) {
|
||||
return new HeaderParameter().name(name).description(description).required(required)
|
||||
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.common.openapi;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaOpenApiGroupContractTest {
|
||||
@Test
|
||||
void pdaGroupOnlyMatchesDeviceApis() {
|
||||
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
|
||||
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
|
||||
|
||||
assertThat(group.getGroup()).isEqualTo("pda");
|
||||
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
|
||||
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
|
||||
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
|
||||
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
|
||||
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
|
||||
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
|
||||
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
|
||||
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
|
||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
|
||||
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
|
||||
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
|
||||
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
|
||||
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
|
||||
- 从父目录运行 `mvn -pl common-security -am test`。
|
||||
|
||||
@@ -7,13 +7,36 @@ import java.util.Set;
|
||||
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
|
||||
*/
|
||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType,
|
||||
String username, String displayName, String appCode, String clientType,
|
||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
|
||||
Long actorMembershipId, boolean passwordChangeRequired,
|
||||
Long overseasWarehouseId) implements Principal {
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
|
||||
permissions, false, null, null, false);
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, false, null, null, false, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions,
|
||||
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||
boolean passwordChangeRequired) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String displayName, String appCode, String clientType,
|
||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||
Long actorMembershipId, boolean passwordChangeRequired) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions,
|
||||
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||
boolean passwordChangeRequired, Long overseasWarehouseId) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
|
||||
overseasWarehouseId);
|
||||
}
|
||||
public AuthenticatedUser {
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
|
||||
@@ -37,13 +37,15 @@ public class JwtClaimsMapper {
|
||||
longClaim(jwt, "operator_tenant_id"),
|
||||
longClaim(jwt, "membership_id"),
|
||||
username,
|
||||
firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")),
|
||||
appCode,
|
||||
jwt.getClaimAsString("client_type"),
|
||||
permissions,
|
||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||
longClaim(jwt, "actor_user_id"),
|
||||
longClaim(jwt, "actor_membership_id"),
|
||||
Boolean.TRUE.equals(jwt.getClaim("password_change_required")));
|
||||
Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
|
||||
longClaim(jwt, "overseas_warehouse_id"));
|
||||
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
||||
.map(SimpleGrantedAuthority::new).toList();
|
||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||
@@ -86,4 +88,9 @@ public class JwtClaimsMapper {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private String firstNonBlankNullable(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
|
||||
.claim("actor_user_id", 42L)
|
||||
.claim("actor_membership_id", 43L)
|
||||
.claim("password_change_required", true)
|
||||
.claim("overseas_warehouse_id", 900L)
|
||||
.build();
|
||||
|
||||
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
||||
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
|
||||
assertThat(user.actorUserId()).isEqualTo(42L);
|
||||
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
||||
assertThat(user.passwordChangeRequired()).isTrue();
|
||||
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|---|---|
|
||||
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
|
||||
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
|
||||
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
|
||||
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
|
||||
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
|
||||
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
||||
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
||||
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
|
||||
@@ -23,5 +26,8 @@
|
||||
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
|
||||
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
|
||||
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
|
||||
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
|
||||
- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。
|
||||
- 从父目录运行 `mvn -pl common-web -am test`。
|
||||
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-core</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
|
||||
public final class CorrelationContext {
|
||||
public static final String REQUEST_ID = "requestId";
|
||||
public static final String TRACE_ID = "traceId";
|
||||
public static final String SPAN_ID = "spanId";
|
||||
public static final String TRACE_PARENT = "traceparent";
|
||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
||||
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
|
||||
|
||||
private CorrelationContext() {}
|
||||
|
||||
@@ -22,6 +25,14 @@ public final class CorrelationContext {
|
||||
return value(request, TRACE_ID);
|
||||
}
|
||||
|
||||
public static String spanId(HttpServletRequest request) {
|
||||
return value(request, SPAN_ID);
|
||||
}
|
||||
|
||||
public static String traceParent(HttpServletRequest request) {
|
||||
return value(request, TRACE_PARENT);
|
||||
}
|
||||
|
||||
private static String value(HttpServletRequest request, String key) {
|
||||
Object attribute = request.getAttribute(key);
|
||||
return attribute == null ? MDC.get(key) : attribute.toString();
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false);
|
||||
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true);
|
||||
String requestId = requestId(request);
|
||||
TraceParent traceParent = traceParent(request);
|
||||
String traceId = traceParent.traceId();
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
|
||||
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
|
||||
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
|
||||
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
|
||||
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
||||
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
|
||||
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
|
||||
|
||||
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
|
||||
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) {
|
||||
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
|
||||
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
private String acceptedOrGenerated(String candidate, boolean compact) {
|
||||
if (candidate != null && SAFE_ID.matcher(candidate).matches()) {
|
||||
return candidate;
|
||||
private String requestId(HttpServletRequest request) {
|
||||
HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
|
||||
if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
|
||||
return header.value();
|
||||
}
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private TraceParent traceParent(HttpServletRequest request) {
|
||||
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
|
||||
TraceParent.HEADER_LENGTH);
|
||||
if (standard.present()) {
|
||||
if (standard.valid()) {
|
||||
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
|
||||
.orElseGet(() -> TraceParent.newRoot(null));
|
||||
}
|
||||
return TraceParent.newRoot(null);
|
||||
}
|
||||
|
||||
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
|
||||
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
|
||||
}
|
||||
|
||||
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
|
||||
Enumeration<String> values = request.getHeaders(name);
|
||||
if (values == null || !values.hasMoreElements()) {
|
||||
return HeaderValue.missing();
|
||||
}
|
||||
String value = values.nextElement();
|
||||
boolean duplicate = values.hasMoreElements();
|
||||
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
|
||||
}
|
||||
|
||||
private record HeaderValue(boolean present, boolean valid, String value) {
|
||||
private static HeaderValue missing() {
|
||||
return new HeaderValue(false, false, null);
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
return compact ? id.replace("-", "") : id;
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/** Enables synchronous Feign trace propagation without making Feign a required dependency. */
|
||||
@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration")
|
||||
@ConditionalOnClass(name = "feign.RequestInterceptor")
|
||||
public class FeignTracePropagationAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(FeignTraceRequestInterceptor.class)
|
||||
FeignTraceRequestInterceptor feignTraceRequestInterceptor() {
|
||||
return new FeignTraceRequestInterceptor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import feign.RequestInterceptor;
|
||||
import feign.RequestTemplate;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Propagates the trusted synchronous request trace context to every Feign invocation. */
|
||||
public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered {
|
||||
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
@Override
|
||||
public void apply(RequestTemplate template) {
|
||||
removeManagedHeaders(template);
|
||||
|
||||
String requestId = MDC.get(CorrelationContext.REQUEST_ID);
|
||||
if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) {
|
||||
template.header(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
||||
}
|
||||
|
||||
String traceId = MDC.get(CorrelationContext.TRACE_ID);
|
||||
if (TraceParent.validLegacyTraceId(traceId)) {
|
||||
TraceParent outbound = TraceParent.outboundChild(traceId);
|
||||
template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
|
||||
template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
|
||||
private void removeManagedHeaders(RequestTemplate template) {
|
||||
template.removeHeader(CorrelationContext.REQUEST_ID_HEADER);
|
||||
template.removeHeader(CorrelationContext.TRACE_ID_HEADER);
|
||||
template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Immutable standard headers for one synchronous outbound child span. */
|
||||
public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) {
|
||||
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
/** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */
|
||||
public static OutboundTraceHeaders fromCurrentOrNew() {
|
||||
String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID));
|
||||
String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID))
|
||||
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||
return child(requestId, traceId);
|
||||
}
|
||||
|
||||
/** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */
|
||||
public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) {
|
||||
String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId)
|
||||
.orElseGet(() -> TraceParent.newRoot(null).traceId());
|
||||
return child(UUID.randomUUID().toString(), traceId);
|
||||
}
|
||||
|
||||
public String spanId() {
|
||||
return TraceParent.parse(traceParent).orElseThrow().parentId();
|
||||
}
|
||||
|
||||
private static OutboundTraceHeaders child(String requestId, String traceId) {
|
||||
TraceParent child = TraceParent.outboundChild(traceId);
|
||||
return new OutboundTraceHeaders(requestId, child.traceId(), child.value());
|
||||
}
|
||||
|
||||
private static String safeRequestId(String value) {
|
||||
return value != null && SAFE_REQUEST_ID.matcher(value).matches()
|
||||
? value : UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Installs the trace interceptor after ordinary RestClient customizers. */
|
||||
public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered {
|
||||
private final RestClientTraceInterceptor interceptor;
|
||||
|
||||
public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) {
|
||||
this.interceptor = interceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customize(RestClient.Builder builder) {
|
||||
builder.requestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.http.HttpRequest;
|
||||
import org.springframework.http.client.ClientHttpRequestExecution;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Applies the trusted synchronous trace context to Spring RestClient requests. */
|
||||
public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor {
|
||||
@Override
|
||||
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
|
||||
ClientHttpRequestExecution execution) throws IOException {
|
||||
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
|
||||
request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId());
|
||||
request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
|
||||
request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
|
||||
return execution.execute(request, body);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Adds trace propagation to Spring-managed RestClient builders. */
|
||||
@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration")
|
||||
@ConditionalOnClass({RestClient.class, RestClientCustomizer.class})
|
||||
public class RestClientTracePropagationAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(RestClientTraceInterceptor.class)
|
||||
RestClientTraceInterceptor restClientTraceInterceptor() {
|
||||
return new RestClientTraceInterceptor();
|
||||
}
|
||||
|
||||
@Bean
|
||||
RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) {
|
||||
return new RestClientTraceCustomizer(interceptor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Optional;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Strict W3C {@code traceparent} version 00 value.
|
||||
*
|
||||
* <p>This type deliberately accepts only the currently frozen version-00 shape. Unsupported,
|
||||
* ambiguous or malformed input must start a new trace instead of being partially trusted.</p>
|
||||
*/
|
||||
public record TraceParent(String traceId, String parentId, String traceFlags) {
|
||||
public static final String HEADER = "traceparent";
|
||||
public static final int HEADER_LENGTH = 55;
|
||||
|
||||
private static final Pattern VERSION_00 = Pattern.compile(
|
||||
"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})");
|
||||
private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}");
|
||||
private static final String ZERO_TRACE_ID = "0".repeat(32);
|
||||
private static final String ZERO_PARENT_ID = "0".repeat(16);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
public TraceParent {
|
||||
if (!validTraceId(traceId) || !validParentId(parentId)
|
||||
|| traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) {
|
||||
throw new IllegalArgumentException("Invalid W3C traceparent components");
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses one exact, lowercase W3C version-00 header value. */
|
||||
public static Optional<TraceParent> parse(String value) {
|
||||
if (value == null || value.length() != HEADER_LENGTH) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Matcher matcher = VERSION_00.matcher(value);
|
||||
if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3)));
|
||||
}
|
||||
|
||||
/** Creates the local inbound context for a valid remote parent. */
|
||||
public static TraceParent childOf(TraceParent remoteParent) {
|
||||
return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags());
|
||||
}
|
||||
|
||||
/** Creates a new root context, optionally reusing a valid legacy trace ID. */
|
||||
public static TraceParent newRoot(String legacyTraceId) {
|
||||
String traceId = validLegacyTraceId(legacyTraceId)
|
||||
? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16);
|
||||
return new TraceParent(traceId, randomNonZero(8), "00");
|
||||
}
|
||||
|
||||
/** Creates a sampled outbound child for a trusted current trace ID. */
|
||||
public static TraceParent outboundChild(String currentTraceId) {
|
||||
if (!validLegacyTraceId(currentTraceId)) {
|
||||
throw new IllegalArgumentException("Current trace ID is invalid");
|
||||
}
|
||||
return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a trace ID from a trusted persisted/internal context.
|
||||
*
|
||||
* <p>Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because
|
||||
* existing event envelopes historically persisted UUID trace IDs.</p>
|
||||
*/
|
||||
public static Optional<String> normalizeTrustedTraceId(String value) {
|
||||
if (validLegacyTraceId(value)) {
|
||||
return Optional.of(value.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (value != null && value.matches(
|
||||
"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) {
|
||||
String compact = value.replace("-", "").toLowerCase(Locale.ROOT);
|
||||
return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static boolean validLegacyTraceId(String value) {
|
||||
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
|
||||
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
|
||||
}
|
||||
|
||||
public String value() {
|
||||
return "00-" + traceId + '-' + parentId + '-' + traceFlags;
|
||||
}
|
||||
|
||||
private static boolean validTraceId(String value) {
|
||||
return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value);
|
||||
}
|
||||
|
||||
private static boolean validParentId(String value) {
|
||||
return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value);
|
||||
}
|
||||
|
||||
private static String randomNonZero(int byteCount) {
|
||||
byte[] bytes = new byte[byteCount];
|
||||
String value;
|
||||
do {
|
||||
RANDOM.nextBytes(bytes);
|
||||
value = HEX.formatHex(bytes);
|
||||
} while (value.chars().allMatch(character -> character == '0'));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+2
@@ -1,2 +1,4 @@
|
||||
com.baiyue.common.web.LongIdJacksonConfiguration
|
||||
com.baiyue.common.web.CommonWebAutoConfiguration
|
||||
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
|
||||
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
@@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
|
||||
assertThat(context).hasSingleBean(CorrelationIdFilter.class);
|
||||
assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value())
|
||||
.isEqualTo(Ordered.HIGHEST_PRECEDENCE);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("请求链路标识过滤功能")
|
||||
@@ -13,27 +15,87 @@ class CorrelationIdFilterTest {
|
||||
private final CorrelationIdFilter filter = new CorrelationIdFilter();
|
||||
|
||||
@Test
|
||||
void preservesSafeIncomingIds() throws Exception {
|
||||
void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
|
||||
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123");
|
||||
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
CapturingFilterChain chain = new CapturingFilterChain();
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
|
||||
assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123");
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
|
||||
assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull();
|
||||
assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesUnsafeIncomingIds() throws Exception {
|
||||
void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n");
|
||||
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||
.isEqualTo("abcdef0123456789abcdef0123456789");
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01");
|
||||
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||
.matches("[0-9a-f]{32}")
|
||||
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
.isNotEqualTo("00000000000000000000000000000000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateAndOversizedHeaders() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129));
|
||||
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
||||
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
|
||||
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.matches("[0-9a-f-]{36}");
|
||||
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
|
||||
.matches("[0-9a-f]{32}");
|
||||
.matches("[0-9a-f]{32}")
|
||||
.isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736")
|
||||
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
}
|
||||
|
||||
private static final class CapturingFilterChain extends MockFilterChain {
|
||||
private String traceId;
|
||||
private String spanId;
|
||||
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response) throws IOException {
|
||||
traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID);
|
||||
spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import feign.RequestInterceptor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.FilteredClassLoader;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FeignTracePropagationAutoConfigurationTest {
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void registersTraceInterceptorWhenFeignIsPresent() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class);
|
||||
assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotLoadFeignConfigurationWhenFeignIsAbsent() {
|
||||
contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class))
|
||||
.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void backsOffOnlyForAnApplicationProvidedTraceInterceptor() {
|
||||
contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new)
|
||||
.run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import feign.RequestTemplate;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FeignTraceRequestInterceptorTest {
|
||||
private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor();
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runsLastSoTrustedContextHasFinalHeaderAuthority() {
|
||||
assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
RequestTemplate template = templateWithSpoofedHeaders();
|
||||
|
||||
interceptor.apply(template);
|
||||
|
||||
assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.containsExactly("req-trusted-123");
|
||||
assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER))
|
||||
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.singleElement().asString()
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsDifferentNonZeroSpanForEachSynchronousInvocation() {
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
RequestTemplate first = new RequestTemplate();
|
||||
RequestTemplate second = new RequestTemplate();
|
||||
|
||||
interceptor.apply(first);
|
||||
interceptor.apply(second);
|
||||
|
||||
assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "invalid request id");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id");
|
||||
RequestTemplate template = templateWithSpoofedHeaders();
|
||||
|
||||
interceptor.apply(template);
|
||||
|
||||
assertThat(template.headers()).doesNotContainKeys(
|
||||
CorrelationContext.REQUEST_ID_HEADER,
|
||||
CorrelationContext.TRACE_ID_HEADER,
|
||||
CorrelationContext.TRACE_PARENT_HEADER);
|
||||
}
|
||||
|
||||
private RequestTemplate templateWithSpoofedHeaders() {
|
||||
RequestTemplate template = new RequestTemplate();
|
||||
template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter");
|
||||
template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
template.header(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||
return template;
|
||||
}
|
||||
|
||||
private String singleHeader(RequestTemplate template, String name) {
|
||||
return template.headers().get(name).iterator().next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OutboundTraceHeadersTest {
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentContextCreatesAValidChildWithoutUsingBusinessValues() {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
|
||||
OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew();
|
||||
|
||||
assertThat(headers.requestId()).isEqualTo("req-trusted");
|
||||
assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(headers.traceParent())
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() {
|
||||
OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||
"550e8400-e29b-41d4-a716-446655440000");
|
||||
OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew(
|
||||
"ORDER:customer-99:booking-123");
|
||||
|
||||
assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000");
|
||||
assertThat(invalid.traceId()).matches("[0-9a-f]{32}")
|
||||
.isNotEqualTo("ORDER:customer-99:booking-123");
|
||||
assertThat(invalid.requestId()).matches("[0-9a-f-]{36}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.mock.http.client.MockClientHttpRequest;
|
||||
import org.springframework.mock.http.client.MockClientHttpResponse;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RestClientTraceInterceptorTest {
|
||||
private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor();
|
||||
|
||||
@AfterEach
|
||||
void clearMdc() {
|
||||
MDC.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception {
|
||||
MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client");
|
||||
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||
request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id");
|
||||
request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER,
|
||||
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
|
||||
AtomicReference<org.springframework.http.HttpHeaders> captured = new AtomicReference<>();
|
||||
|
||||
interceptor.intercept(request, new byte[0], (outbound, body) -> {
|
||||
captured.set(outbound.getHeaders());
|
||||
return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK);
|
||||
});
|
||||
|
||||
assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.containsExactly("req-rest-client");
|
||||
assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER))
|
||||
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception {
|
||||
MDC.put(CorrelationContext.TRACE_ID, "business-order-123");
|
||||
MockClientHttpRequest request = new MockClientHttpRequest();
|
||||
|
||||
interceptor.intercept(request, new byte[0], (outbound, body) ->
|
||||
new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK));
|
||||
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER))
|
||||
.matches("[0-9a-f-]{36}");
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER))
|
||||
.matches("[0-9a-f]{32}").isNotEqualTo("business-order-123");
|
||||
assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER))
|
||||
.matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01");
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.boot.web.client.RestClientCustomizer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class RestClientTracePropagationAutoConfigurationTest {
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void registersInterceptorAndIndependentCustomizer() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(RestClientTraceInterceptor.class);
|
||||
assertThat(context).hasBean("baiyueTraceRestClientCustomizer");
|
||||
assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1);
|
||||
RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class);
|
||||
assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
|
||||
RestClient.Builder builder = mock(RestClient.Builder.class);
|
||||
customizer.customize(builder);
|
||||
verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesApplicationProvidedTraceInterceptor() {
|
||||
contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new)
|
||||
.run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TraceParentTest {
|
||||
|
||||
@Test
|
||||
void parsesCanonicalVersion00AndCreatesLocalChild() {
|
||||
TraceParent remote = TraceParent.parse(
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow();
|
||||
|
||||
TraceParent local = TraceParent.childOf(remote);
|
||||
|
||||
assertThat(local.traceId()).isEqualTo(remote.traceId());
|
||||
assertThat(local.parentId()).matches("[0-9a-f]{16}")
|
||||
.isNotEqualTo(remote.parentId())
|
||||
.isNotEqualTo("0000000000000000");
|
||||
assertThat(local.traceFlags()).isEqualTo("01");
|
||||
assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() {
|
||||
assertThat(TraceParent.parse(
|
||||
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty();
|
||||
assertThat(TraceParent.parse(
|
||||
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty();
|
||||
assertThat(TraceParent.parse(
|
||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty();
|
||||
assertThat(TraceParent.parse(
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty();
|
||||
assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsCanonicalNonZeroRootAndNormalizesLegacyHex() {
|
||||
TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789");
|
||||
|
||||
assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789");
|
||||
assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
|
||||
assertThat(root.value()).matches(
|
||||
"00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsSampledOutboundChildForTrustedCurrentTrace() {
|
||||
TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736");
|
||||
|
||||
assertThat(child.value()).matches(
|
||||
"00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() {
|
||||
assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000"))
|
||||
.contains("550e8400e29b41d4a716446655440000");
|
||||
assertThat(TraceParent.parse(
|
||||
"00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ spring:
|
||||
password: ${IAM_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
security-event:
|
||||
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
|
||||
customer-dimension-initialization:
|
||||
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
|
||||
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
|
||||
notification:
|
||||
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
|
||||
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
|
||||
|
||||
@@ -10,8 +10,19 @@
|
||||
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 模板、参数和影响行数。
|
||||
|
||||
@@ -5,5 +5,7 @@ spring.datasource:
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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}
|
||||
@@ -4,6 +4,9 @@ spring.datasource:
|
||||
username: ${IAM_DB_USERNAME:root}
|
||||
password: ${IAM_DB_PASSWORD:123456}
|
||||
baiyue:
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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: claims
|
||||
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}
|
||||
@@ -0,0 +1,19 @@
|
||||
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: claims
|
||||
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}
|
||||
@@ -1,5 +1,19 @@
|
||||
# 海外 Nacos 配置
|
||||
|
||||
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
|
||||
本目录是海外远程部署模板,由 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 提供;不要把真实值写入本目录。
|
||||
|
||||
@@ -8,6 +8,10 @@ baiyue:
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ baiyue:
|
||||
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}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
springdoc:
|
||||
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||
baiyue:
|
||||
security:
|
||||
overseas-projection:
|
||||
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
|
||||
|
||||
@@ -10,6 +10,20 @@ lastleg:
|
||||
smart-dispatch-api:
|
||||
enabled: true
|
||||
baiyue:
|
||||
http:
|
||||
clients:
|
||||
unload-photo-file:
|
||||
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
|
||||
relabel-photo-file:
|
||||
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
|
||||
warehouse-resource:
|
||||
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
|
||||
pallet-physical:
|
||||
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
|
||||
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
|
||||
clients:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||
@@ -21,7 +35,7 @@ baiyue:
|
||||
warehouse:
|
||||
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
|
||||
firstleg:
|
||||
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL}
|
||||
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
|
||||
outbox-relay:
|
||||
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
|
||||
@@ -4,3 +4,8 @@ spring:
|
||||
username: ${WAREHOUSE_DB_USERNAME}
|
||||
password: ${WAREHOUSE_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
clients:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||
ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
@@ -45,12 +45,16 @@ services:
|
||||
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
||||
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
||||
AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL}
|
||||
PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s}
|
||||
LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303}
|
||||
LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s}
|
||||
|
||||
lastleg-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||
ports: ["${LASTLEG_PORT:-9303}:9303"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
@@ -58,7 +62,8 @@ services:
|
||||
LASTLEG_DB_URL: ${LASTLEG_DB_URL}
|
||||
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
|
||||
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL}
|
||||
CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
|
||||
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
||||
@@ -67,7 +72,7 @@ services:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||
ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
@@ -75,12 +80,13 @@ services:
|
||||
WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}
|
||||
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
|
||||
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
|
||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||
|
||||
event-bridge-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||
ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
@@ -89,6 +95,11 @@ services:
|
||||
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
|
||||
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
|
||||
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events}
|
||||
LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events}
|
||||
networks:
|
||||
firstleg-overseas-network:
|
||||
infra-network:
|
||||
aliases: [event-bridge-overseas]
|
||||
|
||||
networks:
|
||||
firstleg-overseas-network:
|
||||
|
||||
+16
-11
@@ -39,7 +39,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
||||
ports: ["${GATEWAY_PORT:-9000}:9000"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
||||
ports: ["${AUTH_PORT:-9100}:9100"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
||||
ports: ["${IAM_PORT:-9201}:9201"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -79,12 +79,13 @@ services:
|
||||
IAM_DB_URL: ${IAM_DB_URL}
|
||||
IAM_DB_USERNAME: ${IAM_DB_USERNAME}
|
||||
IAM_DB_PASSWORD: ${IAM_DB_PASSWORD}
|
||||
SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
|
||||
|
||||
firstleg:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
||||
ports: ["${FIRSTLEG_PORT:-9202}:9202"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -110,7 +111,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
||||
ports: ["${FINANCE_PORT:-9203}:9203"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -123,7 +124,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
||||
ports: ["${ORDER_PORT:-9204}:9204"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -136,7 +137,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
||||
ports: ["${WAREHOUSE_PORT:-9205}:9205"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -149,7 +150,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-file
|
||||
ports: ["${FILE_PORT:-9206}:9206"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
||||
volumes:
|
||||
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
||||
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
|
||||
@@ -173,7 +174,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
||||
ports: ["${PRINT_PORT:-9207}:9207"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -184,7 +185,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
||||
ports: ["${INTEGRATION_PORT:-9210}:9210"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -234,7 +235,7 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
||||
ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
@@ -245,6 +246,10 @@ services:
|
||||
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||
SPRING_SQL_INIT_MODE: never
|
||||
networks:
|
||||
firstleg-network:
|
||||
infra-network:
|
||||
aliases: [event-bridge-cn]
|
||||
|
||||
audit-service:
|
||||
<<: *common
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
ARG BASE_IMAGE=docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine
|
||||
ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine
|
||||
FROM ${BASE_IMAGE} AS extractor
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
ARG JAR_FILE
|
||||
COPY ${JAR_FILE} app.jar
|
||||
RUN java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN addgroup -S app \
|
||||
@@ -9,8 +17,11 @@ WORKDIR /app
|
||||
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
|
||||
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs
|
||||
|
||||
ARG JAR_FILE
|
||||
COPY --chown=app:app ${JAR_FILE} /app/app.jar
|
||||
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
|
||||
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
|
||||
COPY --from=extractor --chown=app:app /workspace/spring-boot-loader/ ./
|
||||
COPY --from=extractor --chown=app:app /workspace/snapshot-dependencies/ ./
|
||||
COPY --from=extractor --chown=app:app /workspace/application/ ./
|
||||
|
||||
USER app
|
||||
|
||||
@@ -22,4 +33,4 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD busybox wget -q -O /dev/null "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]
|
||||
|
||||
@@ -4,54 +4,36 @@
|
||||
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<name>柏悦 Nexus Maven 聚合仓库</name>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<mirrorOf>external:*</mirrorOf>
|
||||
<id>aliyun-public</id>
|
||||
<name>阿里云 Maven 公共镜像</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
|
||||
<servers>
|
||||
<server>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
<server>
|
||||
<id>baiyue-nexus-releases</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
<server>
|
||||
<id>baiyue-nexus-snapshots</id>
|
||||
<username>${env.NEXUS_USERNAME}</username>
|
||||
<password>${env.NEXUS_PASSWORD}</password>
|
||||
</server>
|
||||
</servers>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>柏悦 Nexus</id>
|
||||
<id>国内公共镜像</id>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>baiyue-nexus-public</id>
|
||||
<url>http://8.163.30.128:18081/repository/maven-public/</url>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
<activeProfiles>
|
||||
<activeProfile>柏悦 Nexus</activeProfile>
|
||||
<activeProfile>国内公共镜像</activeProfile>
|
||||
</activeProfiles>
|
||||
</settings>
|
||||
|
||||
@@ -13,7 +13,20 @@ SERVICE="$4"
|
||||
ENV_FILE="$5"
|
||||
COMPOSE_FILE="deploy/docker-compose.yml"
|
||||
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
|
||||
REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
|
||||
PROJECT_NAME="firstleg-$DEPLOY_ENV"
|
||||
HEALTH_TIMEOUT_SECONDS="${DEPLOY_HEALTH_TIMEOUT_SECONDS:-240}"
|
||||
HEALTH_POLL_SECONDS="${DEPLOY_HEALTH_POLL_SECONDS:-5}"
|
||||
|
||||
for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do
|
||||
eval "value=\${$value_name}"
|
||||
case "$value" in
|
||||
''|*[!0-9]*|0)
|
||||
echo "$value_name 必须为正整数,当前: $value" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$DEPLOY_ENV" in
|
||||
development|test) ;;
|
||||
@@ -23,6 +36,13 @@ case "$DEPLOY_ENV" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$DEPLOY_ENV" = "development" ]; then
|
||||
PUBLISHED_BIND_ADDRESS=127.0.0.1
|
||||
else
|
||||
PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
|
||||
fi
|
||||
export PUBLISHED_BIND_ADDRESS
|
||||
|
||||
case "$SERVICE" in
|
||||
*[!A-Za-z0-9_-]*|'')
|
||||
echo "非法服务名: $SERVICE" >&2
|
||||
@@ -71,6 +91,13 @@ case "$SERVICE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$SERVICE" = "gateway-overseas" ]; then
|
||||
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
|
||||
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "必需文件不存在: $file" >&2
|
||||
@@ -157,20 +184,156 @@ if [ "$SERVICE" = "file" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
docker image inspect "$IMAGE" >/dev/null
|
||||
: "${REGISTRY_USER:?REGISTRY_USER 未设置}"
|
||||
: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}"
|
||||
|
||||
local_registry_logout() {
|
||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
local_compose() {
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f "$COMPOSE_FILE" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
local_container_id() {
|
||||
local_compose ps --all --quiet "$SERVICE" | head -n 1
|
||||
}
|
||||
|
||||
local_container_image() {
|
||||
container_id="$(local_container_id)"
|
||||
if [ -n "$container_id" ]; then
|
||||
docker inspect --format '{{.Config.Image}}' "$container_id"
|
||||
fi
|
||||
}
|
||||
|
||||
local_health_state() {
|
||||
container_id="$(local_container_id)"
|
||||
if [ -z "$container_id" ]; then
|
||||
echo missing
|
||||
return
|
||||
fi
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing
|
||||
}
|
||||
|
||||
local_runtime_state() {
|
||||
container_id="$(local_container_id)"
|
||||
if [ -z "$container_id" ]; then
|
||||
echo missing
|
||||
return
|
||||
fi
|
||||
docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing
|
||||
}
|
||||
|
||||
wait_for_local_health() {
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
|
||||
runtime_state="$(local_runtime_state)"
|
||||
health_state="$(local_health_state)"
|
||||
echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)"
|
||||
case "$runtime_state" in
|
||||
restarting|exited|dead)
|
||||
echo "容器未保持运行,立即停止等待: $runtime_state" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
case "$health_state" in
|
||||
healthy)
|
||||
return 0
|
||||
;;
|
||||
no-health:running)
|
||||
echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2
|
||||
return 0
|
||||
;;
|
||||
unhealthy|no-health:exited|no-health:dead)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
sleep "$HEALTH_POLL_SECONDS"
|
||||
elapsed=$((elapsed + HEALTH_POLL_SECONDS))
|
||||
done
|
||||
echo "等待容器健康超时: ${HEALTH_TIMEOUT_SECONDS}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
deploy_local() {
|
||||
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
trap local_registry_logout EXIT HUP INT TERM
|
||||
previous_image="$(local_container_image || true)"
|
||||
if [ -n "$previous_image" ]; then
|
||||
echo "部署前运行镜像: $previous_image"
|
||||
else
|
||||
echo "首次部署,未发现可回滚的旧镜像"
|
||||
fi
|
||||
docker pull "$IMAGE"
|
||||
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f "$COMPOSE_FILE" \
|
||||
up -d --remove-orphans --pull never "$SERVICE"
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f "$COMPOSE_FILE" \
|
||||
ps "$SERVICE"
|
||||
new_started=true
|
||||
if ! local_compose up -d --no-deps --pull never "$SERVICE"; then
|
||||
echo "新镜像启动命令失败: $IMAGE" >&2
|
||||
new_started=false
|
||||
fi
|
||||
if [ "$new_started" = "true" ] && wait_for_local_health; then
|
||||
running_image="$(local_container_image || true)"
|
||||
if [ "$running_image" != "$IMAGE" ]; then
|
||||
echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "新镜像健康检查通过: $IMAGE"
|
||||
local_compose ps "$SERVICE"
|
||||
local_registry_logout
|
||||
trap - EXIT HUP INT TERM
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "新镜像健康检查失败: $IMAGE" >&2
|
||||
failed_container_id="$(local_container_id || true)"
|
||||
if [ -n "$failed_container_id" ]; then
|
||||
echo "新镜像容器最近日志(最多 200 行):" >&2
|
||||
docker logs --tail 200 "$failed_container_id" 2>&1 || true
|
||||
else
|
||||
echo "新镜像容器不存在,无法读取启动日志" >&2
|
||||
fi
|
||||
expected_prefix="$IMAGE_REPOSITORY/$SERVICE:"
|
||||
case "$previous_image" in
|
||||
"$expected_prefix"*)
|
||||
previous_tag="${previous_image#"$expected_prefix"}"
|
||||
;;
|
||||
'')
|
||||
echo "首次部署没有可回滚镜像,部署失败" >&2
|
||||
local_compose stop "$SERVICE" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if [ "$previous_image" = "$IMAGE" ]; then
|
||||
echo "旧镜像与新镜像相同,无法自动回滚: $IMAGE" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "开始自动回滚到: $previous_image" >&2
|
||||
if ! docker pull "$previous_image"; then
|
||||
echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2
|
||||
return 1
|
||||
fi
|
||||
IMAGE_TAG="$previous_tag"
|
||||
export IMAGE_TAG
|
||||
if ! local_compose up -d --no-deps --pull never "$SERVICE"; then
|
||||
echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2
|
||||
return 1
|
||||
fi
|
||||
if wait_for_local_health; then
|
||||
echo "自动回滚成功: $previous_image" >&2
|
||||
local_compose ps "$SERVICE" || true
|
||||
else
|
||||
echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
|
||||
@@ -232,10 +395,16 @@ mv -f "$env_tmp" "$env_target"
|
||||
mv -f "$compose_tmp" "$compose_target"
|
||||
REMOTE_PREPARE
|
||||
|
||||
echo "传输镜像到 $SSH_TARGET: $IMAGE"
|
||||
docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load'
|
||||
remote_registry_logout() {
|
||||
run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" <<'REMOTE_DEPLOY'
|
||||
echo "目标服务器从共享仓库拉取增量镜像: $IMAGE"
|
||||
printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
||||
trap remote_registry_logout EXIT HUP INT TERM
|
||||
run_ssh docker pull "$IMAGE"
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
deploy_env="$2"
|
||||
@@ -245,18 +414,135 @@ service="$5"
|
||||
project_name="$6"
|
||||
env_file="$7"
|
||||
compose_file="$8"
|
||||
health_timeout_seconds="$9"
|
||||
health_poll_seconds="${10}"
|
||||
cd "$remote_dir"
|
||||
export DEPLOY_ENV="$deploy_env"
|
||||
export IMAGE_REPOSITORY="$image_repository"
|
||||
export IMAGE_TAG="$image_tag"
|
||||
if [ "$deploy_env" = "development" ]; then
|
||||
export PUBLISHED_BIND_ADDRESS=127.0.0.1
|
||||
else
|
||||
export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
|
||||
fi
|
||||
|
||||
compose() {
|
||||
docker compose \
|
||||
--project-name "$project_name" \
|
||||
--env-file "$env_file" \
|
||||
-f "$compose_file" \
|
||||
up -d --remove-orphans --pull never "$service"
|
||||
docker compose \
|
||||
--project-name "$project_name" \
|
||||
--env-file "$env_file" \
|
||||
-f "$compose_file" \
|
||||
ps "$service"
|
||||
"$@"
|
||||
}
|
||||
|
||||
container_id() {
|
||||
compose ps --all --quiet "$service" | head -n 1
|
||||
}
|
||||
|
||||
container_image() {
|
||||
id="$(container_id)"
|
||||
if [ -n "$id" ]; then
|
||||
docker inspect --format '{{.Config.Image}}' "$id"
|
||||
fi
|
||||
}
|
||||
|
||||
health_state() {
|
||||
id="$(container_id)"
|
||||
if [ -z "$id" ]; then
|
||||
echo missing
|
||||
return
|
||||
fi
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$id" 2>/dev/null || echo missing
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$health_timeout_seconds" ]; do
|
||||
state="$(health_state)"
|
||||
echo "容器健康状态: $state(已等待 ${elapsed}s)"
|
||||
case "$state" in
|
||||
healthy) return 0 ;;
|
||||
no-health:running)
|
||||
echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2
|
||||
return 0
|
||||
;;
|
||||
unhealthy|no-health:exited|no-health:dead) return 1 ;;
|
||||
esac
|
||||
sleep "$health_poll_seconds"
|
||||
elapsed=$((elapsed + health_poll_seconds))
|
||||
done
|
||||
echo "等待容器健康超时: ${health_timeout_seconds}s" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
new_image="$image_repository/$service:$image_tag"
|
||||
previous_image="$(container_image || true)"
|
||||
if [ -n "$previous_image" ]; then
|
||||
echo "部署前运行镜像: $previous_image"
|
||||
else
|
||||
echo "首次部署,未发现可回滚的旧镜像"
|
||||
fi
|
||||
|
||||
new_started=true
|
||||
if ! compose up -d --no-deps --pull never "$service"; then
|
||||
echo "新镜像启动命令失败: $new_image" >&2
|
||||
new_started=false
|
||||
fi
|
||||
if [ "$new_started" = "true" ] && wait_for_health; then
|
||||
running_image="$(container_image || true)"
|
||||
if [ "$running_image" != "$new_image" ]; then
|
||||
echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "新镜像健康检查通过: $new_image"
|
||||
compose ps "$service"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "新镜像健康检查失败: $new_image" >&2
|
||||
failed_container_id="$(remote_container_id || true)"
|
||||
if [ -n "$failed_container_id" ]; then
|
||||
echo "新镜像容器最近日志(最多 200 行):" >&2
|
||||
docker logs --tail 200 "$failed_container_id" 2>&1 || true
|
||||
else
|
||||
echo "新镜像容器不存在,无法读取启动日志" >&2
|
||||
fi
|
||||
expected_prefix="$image_repository/$service:"
|
||||
case "$previous_image" in
|
||||
"$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;;
|
||||
'')
|
||||
echo "首次部署没有可回滚镜像,部署失败" >&2
|
||||
compose stop "$service" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$previous_image" = "$new_image" ]; then
|
||||
echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "开始自动回滚到: $previous_image" >&2
|
||||
if ! docker pull "$previous_image"; then
|
||||
echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2
|
||||
exit 1
|
||||
fi
|
||||
IMAGE_TAG="$previous_tag"
|
||||
export IMAGE_TAG
|
||||
if ! compose up -d --no-deps --pull never "$service"; then
|
||||
echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2
|
||||
exit 1
|
||||
fi
|
||||
if wait_for_health; then
|
||||
echo "自动回滚成功: $previous_image" >&2
|
||||
compose ps "$service" || true
|
||||
else
|
||||
echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2
|
||||
fi
|
||||
exit 1
|
||||
REMOTE_DEPLOY
|
||||
|
||||
remote_registry_logout
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
@@ -64,6 +64,7 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then
|
||||
cd "$REMOTE_DIR"
|
||||
export DEPLOY_ENV=development
|
||||
export IMAGE_REPOSITORY IMAGE_TAG
|
||||
export PUBLISHED_BIND_ADDRESS=127.0.0.1
|
||||
|
||||
docker compose \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
@@ -143,6 +144,7 @@ cd "$remote_dir"
|
||||
export DEPLOY_ENV=development
|
||||
export IMAGE_REPOSITORY="$image_repository"
|
||||
export IMAGE_TAG="$image_tag"
|
||||
export PUBLISHED_BIND_ADDRESS=127.0.0.1
|
||||
|
||||
docker compose \
|
||||
--project-name "$project_name" \
|
||||
|
||||
@@ -80,6 +80,15 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
case " $SERVICES " in
|
||||
*" gateway-overseas "*)
|
||||
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
|
||||
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" finance "*)
|
||||
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
## 唯一数据库初始化与发布入口
|
||||
|
||||
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
|
||||
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先执行 `lastleg-service/src/main/resources/db/lastleg.sql` 基线,再执行该入口。当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。
|
||||
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
|
||||
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。
|
||||
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
|
||||
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
|
||||
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
|
||||
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
|
||||
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
|
||||
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。
|
||||
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。
|
||||
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
|
||||
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
|
||||
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
|
||||
|
||||
+690
-177
File diff suppressed because it is too large
Load Diff
+853
-10
@@ -1,6 +1,113 @@
|
||||
-- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线;
|
||||
-- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。
|
||||
-- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE baiyue_event_bridge_overseas;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_outbox (
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
handover_version BIGINT NULL COMMENT '跨区交接版本',
|
||||
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
|
||||
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
|
||||
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
|
||||
payload_json JSON NOT NULL COMMENT '事件负载',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
|
||||
lease_owner VARCHAR(128) NULL COMMENT '租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '租约截止时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后错误',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
delivered_at DATETIME(3) NULL COMMENT '投递成功时间',
|
||||
PRIMARY KEY (event_id),
|
||||
UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key),
|
||||
KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until)
|
||||
) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_inbox (
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
handover_version BIGINT NULL COMMENT '跨区交接版本',
|
||||
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
|
||||
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
|
||||
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
|
||||
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
|
||||
payload_json JSON NOT NULL COMMENT '事件负载',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态',
|
||||
lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '消费租约截止时间',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数',
|
||||
next_retry_at DATETIME(3) NULL COMMENT '下次消费时间',
|
||||
last_error VARCHAR(1000) NULL COMMENT '最后错误',
|
||||
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
|
||||
processed_at DATETIME(3) NULL COMMENT '消费完成时间',
|
||||
PRIMARY KEY (event_id),
|
||||
UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key),
|
||||
KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until)
|
||||
) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_delivery_attempt (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||
attempt_no INT NOT NULL COMMENT '投递序号',
|
||||
status VARCHAR(32) NOT NULL COMMENT '投递结果',
|
||||
response_code VARCHAR(64) NULL COMMENT '对端响应码',
|
||||
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||
started_at DATETIME(3) NOT NULL COMMENT '开始时间',
|
||||
finished_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bridge_attempt (event_id,attempt_no),
|
||||
KEY idx_bridge_attempt_event (event_id,started_at)
|
||||
) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录';
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_overseas_event_bridge_schema()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_event_bridge_overseas'
|
||||
AND table_name IN ('bridge_outbox','bridge_inbox','bridge_delivery_attempt')) <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 三张表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(table_name, ':', index_name)) FROM information_schema.statistics
|
||||
WHERE table_schema='baiyue_event_bridge_overseas'
|
||||
AND ((table_name='bridge_outbox' AND index_name IN ('PRIMARY','uk_bridge_outbox_idempotency','idx_bridge_outbox_schedule'))
|
||||
OR (table_name='bridge_inbox' AND index_name IN ('PRIMARY','uk_bridge_inbox_idempotency','idx_bridge_inbox_consume'))
|
||||
OR (table_name='bridge_delivery_attempt' AND index_name IN ('PRIMARY','uk_bridge_attempt','idx_bridge_attempt_event')))) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 索引未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_overseas_event_bridge_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE baiyue_lastleg;
|
||||
@@ -88,14 +195,15 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价',
|
||||
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
|
||||
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
|
||||
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
|
||||
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细',
|
||||
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商',
|
||||
domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商',
|
||||
overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID',
|
||||
review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
review_remark VARCHAR(1000) NULL COMMENT '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键',
|
||||
review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
|
||||
@@ -103,16 +211,35 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
|
||||
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外采购订单';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity'));
|
||||
PREPARE procurement_outbound_stmt FROM @ddl; EXECUTE procurement_outbound_stmt; DEALLOCATE PREPARE procurement_outbound_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外采购操作流水';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址',
|
||||
@@ -146,13 +273,34 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单状态历史';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1',
|
||||
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1',
|
||||
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key),
|
||||
KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
|
||||
transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO',
|
||||
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
|
||||
@@ -166,18 +314,165 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id),
|
||||
KEY idx_ll_local_looseboard_list(customer_id,status,created_at), KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板订单';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1',
|
||||
'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id'));
|
||||
PREPARE phase4_looseboard_tail_stmt FROM @ddl; EXECUTE phase4_looseboard_tail_stmt; DEALLOCATE PREPARE phase4_looseboard_tail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1',
|
||||
'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)'));
|
||||
PREPARE phase4_looseboard_tail_stmt FROM @ddl; EXECUTE phase4_looseboard_tail_stmt; DEALLOCATE PREPARE phase4_looseboard_tail_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板操作流水';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1',
|
||||
'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
|
||||
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
|
||||
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key),
|
||||
KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账';
|
||||
|
||||
-- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级;
|
||||
-- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1',
|
||||
'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
|
||||
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
|
||||
UPDATE ll_loading_order_status_history history
|
||||
JOIN ll_order orders ON orders.id=history.order_id
|
||||
SET history.customer_id=orders.customer_id
|
||||
WHERE history.customer_id IS NULL;
|
||||
ALTER TABLE ll_loading_order_status_history
|
||||
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1',
|
||||
'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)'));
|
||||
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
|
||||
|
||||
-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1',
|
||||
'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
|
||||
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
|
||||
UPDATE ll_order_detail detail
|
||||
JOIN ll_order orders ON orders.id=detail.order_id
|
||||
SET detail.customer_id=orders.customer_id
|
||||
WHERE detail.customer_id IS NULL;
|
||||
ALTER TABLE ll_order_detail
|
||||
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1',
|
||||
'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)'));
|
||||
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
|
||||
|
||||
-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号',
|
||||
driver_name VARCHAR(128) NULL COMMENT '司机姓名', driver_phone VARCHAR(64) NULL COMMENT '司机电话',
|
||||
carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线',
|
||||
depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号',
|
||||
fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态',
|
||||
vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态',
|
||||
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态',
|
||||
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数',
|
||||
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(customer_id,dispatch_no),
|
||||
KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate)
|
||||
) ENGINE=InnoDB COMMENT='尾程派车单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NULL COMMENT '入仓计划ID',
|
||||
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
|
||||
order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号',
|
||||
order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_dispatch_item_source(dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
|
||||
KEY idx_ll_dispatch_item_order(customer_id,order_id), KEY idx_ll_dispatch_item_inbound(customer_id,inbound_code),
|
||||
KEY idx_ll_dispatch_item_pallet(customer_id,plan_pallet_id,warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
|
||||
plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
|
||||
draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
|
||||
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
|
||||
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
|
||||
released_at DATETIME(3) NULL COMMENT '释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_active_pallet(customer_id,active_key),
|
||||
KEY idx_ll_dispatch_pallet_lock_order(customer_id,dispatch_order_id,status),
|
||||
KEY idx_ll_dispatch_pallet_lock_source(customer_id,source_pallet_key,status)
|
||||
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_draft (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识',
|
||||
customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID',
|
||||
plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间',
|
||||
released_at DATETIME(3) NULL COMMENT '草稿释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_dispatch_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status)
|
||||
) ENGINE=InnoDB COMMENT='智能排车草稿';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID',
|
||||
draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号',
|
||||
capacity INT NOT NULL COMMENT '草稿车厢托盘容量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_dispatch_draft_container_token(draft_token)
|
||||
) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息'' AFTER order_no'));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_container_id'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id'));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER draft_container_id'));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token'));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at'));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'),
|
||||
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
|
||||
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
|
||||
|
||||
-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
||||
id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -187,7 +482,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
||||
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
||||
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
||||
@@ -199,6 +494,220 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
|
||||
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
|
||||
'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||
UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id='';
|
||||
ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)'));
|
||||
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
|
||||
UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id='';
|
||||
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)'));
|
||||
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
|
||||
UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id='';
|
||||
ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)'));
|
||||
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
|
||||
DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready()
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id)
|
||||
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id)
|
||||
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase4_inbound_plan_customer_scope_ready();
|
||||
DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'),
|
||||
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''',
|
||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code'));
|
||||
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'),
|
||||
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code'));
|
||||
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
|
||||
SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END)
|
||||
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'),
|
||||
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name'));
|
||||
PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'),
|
||||
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id'));
|
||||
PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'),
|
||||
'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no'));
|
||||
PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt;
|
||||
|
||||
UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
|
||||
SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))),
|
||||
o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))),
|
||||
o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL
|
||||
WHERE o.current_status<>'INBOUND_COMPLETED'
|
||||
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'));
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse()
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
|
||||
WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL
|
||||
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_pending_orders_do_not_use_source_warehouse();
|
||||
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
||||
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
|
||||
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
|
||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
|
||||
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'),
|
||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id'));
|
||||
PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_active_receiving_session_has_warehouse()
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL
|
||||
AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_active_receiving_session_has_warehouse();
|
||||
DROP PROCEDURE assert_active_receiving_session_has_warehouse;
|
||||
ALTER TABLE ll_receiving_container_session
|
||||
MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID',
|
||||
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'),
|
||||
'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''',
|
||||
'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status'));
|
||||
PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'),
|
||||
'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1'));
|
||||
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
|
||||
UPDATE ll_receiving_container_session
|
||||
SET active_operator_key=NULL,active_container_key=NULL
|
||||
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_receiving_global_locks_migratable()
|
||||
BEGIN
|
||||
IF EXISTS (SELECT operator_key FROM ll_receiving_container_session
|
||||
WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜';
|
||||
END IF;
|
||||
IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session
|
||||
WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_receiving_global_locks_migratable();
|
||||
DROP PROCEDURE assert_receiving_global_locks_migratable;
|
||||
UPDATE ll_receiving_container_session
|
||||
SET active_operator_key=CONCAT('OPERATOR:',operator_key),
|
||||
active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no)))
|
||||
WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL;
|
||||
ALTER TABLE ll_receiving_container_session
|
||||
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||
MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
|
||||
id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||
session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
|
||||
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照',
|
||||
received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code),
|
||||
KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id),
|
||||
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||
DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_receiving_operation_global_key_migratable()
|
||||
BEGIN
|
||||
IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation
|
||||
GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_receiving_operation_global_key_migratable();
|
||||
DROP PROCEDURE assert_receiving_operation_global_key_migratable;
|
||||
SET @receiving_operation_global_index = (
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT index_name FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
|
||||
AND index_name='uk_ll_receiving_operation'
|
||||
GROUP BY index_name
|
||||
HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key'
|
||||
) ready_index
|
||||
);
|
||||
SET @receiving_operation_named_index = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
|
||||
AND index_name='uk_ll_receiving_operation'
|
||||
);
|
||||
SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1',
|
||||
IF(@receiving_operation_named_index>0,
|
||||
'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)',
|
||||
'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)'));
|
||||
PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
|
||||
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
|
||||
@@ -235,7 +744,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
|
||||
PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code'));
|
||||
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
|
||||
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
||||
@@ -248,8 +758,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
|
||||
'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)'));
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
@@ -270,4 +781,336 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与SKU库存结构已就绪' AS message;
|
||||
-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
|
||||
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
|
||||
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
|
||||
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
|
||||
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点',
|
||||
node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
|
||||
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间',
|
||||
actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
|
||||
location_code VARCHAR(128) NULL COMMENT '节点地点编码',
|
||||
location_name VARCHAR(255) NULL COMMENT '节点地点名称',
|
||||
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码',
|
||||
provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
|
||||
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码',
|
||||
source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
|
||||
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256',
|
||||
aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
|
||||
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本',
|
||||
last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
|
||||
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(container_booking_id),
|
||||
KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
|
||||
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
||||
|
||||
INSERT IGNORE INTO ll_container_shipping_gate (
|
||||
container_booking_id,customer_id,ocean_booking_id,container_no,status,
|
||||
node_sequence,aggregate_version,handover_version,created_at,updated_at
|
||||
)
|
||||
SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY',
|
||||
0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3)
|
||||
FROM ll_order
|
||||
WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> ''
|
||||
AND container_no IS NOT NULL AND container_no <> ''
|
||||
GROUP BY source_container_booking_id,customer_id,container_no;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
|
||||
id BIGINT NOT NULL COMMENT 'PDA作业历史ID',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
|
||||
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型',
|
||||
business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
|
||||
status VARCHAR(32) NOT NULL COMMENT '作业结果状态',
|
||||
idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
|
||||
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希',
|
||||
summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
|
||||
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
|
||||
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation (
|
||||
id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键',
|
||||
operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要',
|
||||
status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED',
|
||||
result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执',
|
||||
last_error VARCHAR(2000) NULL COMMENT '最后失败原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key),
|
||||
KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task (
|
||||
id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET',
|
||||
scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数',
|
||||
counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识',
|
||||
submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no),
|
||||
UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key),
|
||||
KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item (
|
||||
id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识',
|
||||
balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照',
|
||||
product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID',
|
||||
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照',
|
||||
book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数',
|
||||
difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态',
|
||||
position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action (
|
||||
id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
|
||||
action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希',
|
||||
status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
|
||||
id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID',
|
||||
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID',
|
||||
source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID',
|
||||
target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数',
|
||||
position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数',
|
||||
pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希',
|
||||
status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_pda_inventory_move_no(move_no),
|
||||
UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key),
|
||||
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照',
|
||||
reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照',
|
||||
expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数',
|
||||
skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数',
|
||||
completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注',
|
||||
review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注',
|
||||
claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间',
|
||||
reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id),
|
||||
UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at),
|
||||
KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
|
||||
id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
|
||||
application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标',
|
||||
new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
|
||||
exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
|
||||
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
|
||||
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识',
|
||||
action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key),
|
||||
KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_pda_history (
|
||||
id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
|
||||
application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
|
||||
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
|
||||
KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id),
|
||||
KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
|
||||
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
|
||||
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
|
||||
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
|
||||
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
|
||||
|
||||
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
|
||||
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
|
||||
USE baiyue_lastlag_warehouse;
|
||||
-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。
|
||||
-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。
|
||||
DROP TABLE IF EXISTS wh_service_capability;
|
||||
DROP TABLE IF EXISTS wh_warehouse_address;
|
||||
DROP TABLE IF EXISTS wh_warehouse;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
|
||||
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
|
||||
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
|
||||
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
|
||||
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
|
||||
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
|
||||
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
|
||||
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key),
|
||||
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark'));
|
||||
PREPARE phase4_warehouse_pallet_stmt FROM @ddl;
|
||||
EXECUTE phase4_warehouse_pallet_stmt;
|
||||
DEALLOCATE PREPARE phase4_warehouse_pallet_stmt;
|
||||
|
||||
SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet'
|
||||
AND column_name='fba_warehouse_code' AND column_type='varchar(128)');
|
||||
DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase4_warehouse_pallet_ready()
|
||||
BEGIN
|
||||
IF @warehouse_pallet_fba_ready <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase4_warehouse_pallet_ready();
|
||||
DROP PROCEDURE assert_phase4_warehouse_pallet_ready;
|
||||
|
||||
USE baiyue_lastleg;
|
||||
DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase4_pda_operations_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||
'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item',
|
||||
'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task',
|
||||
'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase4_pda_operations_ready();
|
||||
DROP PROCEDURE assert_phase4_pda_operations_ready;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_fee (
|
||||
id BIGINT NOT NULL COMMENT '费用明细主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', fee_item VARCHAR(128) NOT NULL COMMENT '费用项目',
|
||||
currency VARCHAR(3) NOT NULL COMMENT '币种代码', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回',
|
||||
supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', created_by VARCHAR(128) NOT NULL COMMENT '创建操作人',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单费用明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_operation_log (
|
||||
id BIGINT NOT NULL COMMENT '操作记录主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
|
||||
PREPARE ll_inbound_plan_version_ddl FROM @ddl;
|
||||
EXECUTE ll_inbound_plan_version_ddl;
|
||||
DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'
|
||||
), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
|
||||
PREPARE ll_order_merge_batch_ddl FROM @ddl;
|
||||
EXECUTE ll_order_merge_batch_ddl;
|
||||
DEALLOCATE PREPARE ll_order_merge_batch_ddl;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
|
||||
status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED',
|
||||
result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱';
|
||||
CREATE TABLE IF NOT EXISTS ll_security_projection (
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
|
||||
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
|
||||
permission_version BIGINT NULL COMMENT '当前单调权限版本',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
|
||||
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
|
||||
changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间',
|
||||
PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id),
|
||||
KEY idx_security_projection_permission (aggregate_id,permission_version)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
|
||||
|
||||
-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。
|
||||
INSERT IGNORE INTO ll_security_projection (
|
||||
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version,
|
||||
payload_hash,last_event_id,changed_at,updated_at
|
||||
) VALUES
|
||||
('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
|
||||
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
|
||||
('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
|
||||
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_lastleg_security_projection_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_lastleg_security_projection_ready();
|
||||
DROP PROCEDURE assert_lastleg_security_projection_ready;
|
||||
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Order↔Firstleg 在线依赖解环设计
|
||||
|
||||
## 结论
|
||||
|
||||
当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含
|
||||
`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。
|
||||
|
||||
建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有
|
||||
`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和
|
||||
拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留
|
||||
`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。
|
||||
|
||||
## 当前可执行调用
|
||||
|
||||
| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 |
|
||||
|---|---|---|---|---|
|
||||
| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` |
|
||||
| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` |
|
||||
| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 |
|
||||
| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 |
|
||||
| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 |
|
||||
|
||||
## 事实所有权
|
||||
|
||||
- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。
|
||||
- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。
|
||||
- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。
|
||||
- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。
|
||||
|
||||
## 推荐退出方案
|
||||
|
||||
### 1. 先退出货物在线读取
|
||||
|
||||
复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以
|
||||
`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。
|
||||
|
||||
不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。
|
||||
|
||||
### 2. 再将履约拆分改为可靠后台命令
|
||||
|
||||
Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。
|
||||
|
||||
Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。
|
||||
|
||||
这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。
|
||||
|
||||
## 必须由产品冻结的决策
|
||||
|
||||
1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。
|
||||
2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。
|
||||
|
||||
上述任一决策未冻结前,不实施业务解环。
|
||||
|
||||
## 最小实施与验证边界(决策冻结后)
|
||||
|
||||
- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。
|
||||
- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。
|
||||
- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。
|
||||
- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。
|
||||
- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。
|
||||
@@ -0,0 +1,149 @@
|
||||
# 同步调用依赖与失败传播治理基线
|
||||
|
||||
## 目的与口径
|
||||
|
||||
本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
|
||||
|
||||
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
|
||||
|
||||
- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果;
|
||||
- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦;
|
||||
- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用;
|
||||
- `EXTERNAL`:Integration 对第三方供应商的调用。
|
||||
|
||||
逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。
|
||||
|
||||
## 当前服务边
|
||||
|
||||
### OpenFeign
|
||||
|
||||
| 调用方 | 被调用方 | 客户端数 | 主要上下文 |
|
||||
|---|---|---:|---|
|
||||
| Auth | IAM | 2 | 登录授权、注册,`REQUEST` |
|
||||
| IAM | Auth | 1 | 账号开通/管理,`REQUEST` |
|
||||
| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` |
|
||||
| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` |
|
||||
| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` |
|
||||
| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` |
|
||||
| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` |
|
||||
| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` |
|
||||
| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 |
|
||||
| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` |
|
||||
| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` |
|
||||
| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 |
|
||||
| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` |
|
||||
| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` |
|
||||
| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` |
|
||||
| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` |
|
||||
| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` |
|
||||
| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` |
|
||||
|
||||
合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。
|
||||
|
||||
### 原始 HTTP
|
||||
|
||||
| 调用方 | 被调用方 | 实现 | 上下文与当前策略 |
|
||||
|---|---|---|---|
|
||||
| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 |
|
||||
| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 |
|
||||
| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 |
|
||||
| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 |
|
||||
| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 |
|
||||
| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 |
|
||||
| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 |
|
||||
|
||||
#### Lastleg 四个有界 RestClient
|
||||
|
||||
四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。
|
||||
|
||||
| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 |
|
||||
|---|---|---|---|
|
||||
| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
||||
| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
|
||||
| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
||||
| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
|
||||
|
||||
## 分上下文依赖图
|
||||
|
||||
### 在线 REQUEST 图
|
||||
|
||||
在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。
|
||||
|
||||
当前在线强连通分量为:
|
||||
|
||||
`Auth ↔ IAM`;`Firstleg ↔ Order`
|
||||
|
||||
`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。
|
||||
|
||||
### 后台 BACKGROUND 图
|
||||
|
||||
后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为:
|
||||
|
||||
`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order`
|
||||
|
||||
File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。
|
||||
|
||||
### 全图
|
||||
|
||||
全上下文合并后,非平凡强连通分量仍为 8 个服务:
|
||||
|
||||
`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg`
|
||||
|
||||
全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。
|
||||
|
||||
### 用户入口在线最长简单路径
|
||||
|
||||
默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算:
|
||||
|
||||
| 用户入口 | 最长跳数 | 全部长路径 |
|
||||
|---|---:|---|
|
||||
| 订单 API | 4 | `Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse` |
|
||||
| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` |
|
||||
| 财务 API | 5 | `Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse` |
|
||||
| 海外尾程 API/PDA | 3 | `Lastleg → File → IAM → Auth`;`Lastleg → File → IAM → Warehouse` |
|
||||
|
||||
这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。
|
||||
|
||||
### 已完成的第一个 REQUEST 边退出
|
||||
|
||||
`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。
|
||||
|
||||
### Order↔Firstleg 第二阶段结论
|
||||
|
||||
具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。
|
||||
|
||||
## 退出顺序
|
||||
|
||||
1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。
|
||||
2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。
|
||||
3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。
|
||||
4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。
|
||||
5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。
|
||||
6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。
|
||||
7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。
|
||||
8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。
|
||||
|
||||
## 错误、重试与熔断基线
|
||||
|
||||
- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。
|
||||
- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。
|
||||
- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。
|
||||
- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。
|
||||
- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。
|
||||
- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。
|
||||
|
||||
## 直接受影响验证
|
||||
|
||||
| 变更范围 | 最低验证 |
|
||||
|---|---|
|
||||
| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` |
|
||||
| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 |
|
||||
| Auth/IAM | `mvn -pl auth,iam -am test` |
|
||||
| Order/Firstleg | `mvn -pl order,firstleg -am test` |
|
||||
| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
|
||||
| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
|
||||
| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
|
||||
| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 |
|
||||
| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
|
||||
|
||||
以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
|
||||
+12
-7
@@ -10,7 +10,7 @@
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。
|
||||
`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。
|
||||
|
||||
当前技术基线:
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- SpringDoc OpenAPI / Swagger
|
||||
- GitLab、Jenkins、Docker Compose
|
||||
|
||||
本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。
|
||||
国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。
|
||||
|
||||
## 2. 根目录结构
|
||||
|
||||
@@ -39,11 +39,15 @@ baiyue-firstleg/
|
||||
├─ common-event-contract/ # 国内外跨区域事件统一契约
|
||||
│
|
||||
├─ gateway/ # 统一 API 网关
|
||||
├─ gateway-overseas/ # 海外区域 API 网关
|
||||
├─ auth/ # 统一登录与账号凭据
|
||||
├─ iam/ # 租户、成员、角色、权限、企业审核
|
||||
├─ order/ # 头程订单中心
|
||||
├─ firstleg/ # 履约任务、二期入库、测量与计费
|
||||
├─ warehouse/ # 仓库主数据、托盘与库位资源
|
||||
├─ finance/ # 财务账期、账单、收付款与核销
|
||||
├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
|
||||
├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁
|
||||
├─ file/ # 资质、导出与测量图片文件中心
|
||||
├─ print/ # 打印中心
|
||||
├─ audit-service/ # 审计日志中心
|
||||
@@ -95,7 +99,7 @@ baiyue-firstleg/
|
||||
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
|
||||
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
|
||||
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
||||
| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 |
|
||||
| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 |
|
||||
| `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 |
|
||||
| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 |
|
||||
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
|
||||
@@ -170,7 +174,7 @@ config/nacos/
|
||||
└─ cn/ # 国内服务器配置模板
|
||||
```
|
||||
|
||||
每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
|
||||
`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
|
||||
|
||||
### `deploy/`
|
||||
|
||||
@@ -179,7 +183,8 @@ deploy/
|
||||
├─ docker/Dockerfile # 通用 Java 17 镜像
|
||||
├─ env/.env.test.example # 环境变量示例
|
||||
├─ scripts/deploy-services.sh # Jenkins 增量部署脚本
|
||||
├─ docker-compose.yml # 11 个业务服务编排
|
||||
├─ docker-compose.yml # 国内区域服务编排
|
||||
├─ docker-compose.overseas.yml # 海外区域服务编排
|
||||
├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施
|
||||
├─ sql/ # 测试/存量环境统一迁移入口与说明
|
||||
└─ service-map.yml # 模块、JAR、端口映射
|
||||
@@ -276,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新
|
||||
- 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。
|
||||
- Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。
|
||||
- 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。
|
||||
- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。
|
||||
- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。
|
||||
- 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。
|
||||
- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。
|
||||
- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。
|
||||
|
||||
+49
-53
@@ -1,66 +1,62 @@
|
||||
# 数据库环境切换说明
|
||||
# Nacos 环境切换说明
|
||||
|
||||
## 环境定义
|
||||
数据库、Redis 和服务地址均由所选环境的 Nacos 配置决定。本地启动时只切换
|
||||
Nacos 地址与 namespace,不再通过 `application-test.yml` 单独切换数据库。
|
||||
|
||||
- `dev`:本地开发环境,连接 `127.0.0.1:3306`,默认账号为 `root/123456`。
|
||||
- `test`:联调测试环境,连接 `8.163.30.128:3306`,各微服务仍使用独立数据库。
|
||||
## 环境映射
|
||||
|
||||
所有服务默认使用 `dev`:
|
||||
| Maven Profile | Nacos 地址 | Namespace |
|
||||
| --- | --- | --- |
|
||||
| `local` | `127.0.0.1:8848` | `public` |
|
||||
| `dev` | `dev-nacos.buggoglobal.com:8848` | `DEV_NACOS_NAMESPACE` |
|
||||
| `test` | `8.163.30.128:8848` | `d99d7998-5bb0-4ffe-a2a0-1a564ce14463` |
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
本地 Nacos 使用 `nacos/nacos`。开发和测试密码不写入 Git,需在启动 IDEA
|
||||
之前设置一次用户环境变量:
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable('DEV_NACOS_PASSWORD', '<开发 Nacos 密码>', 'User')
|
||||
[Environment]::SetEnvironmentVariable('TEST_NACOS_PASSWORD', '<测试 Nacos 密码>', 'User')
|
||||
[Environment]::SetEnvironmentVariable('DEV_NACOS_NAMESPACE', '<开发海外 namespace>', 'User')
|
||||
```
|
||||
|
||||
建议不要直接修改配置文件。通过 IDEA 的启动配置设置 `Active profiles` 即可切换:
|
||||
设置后完全退出并重新打开 IDEA,使 Maven 能读取新环境变量。
|
||||
|
||||
- 本地数据库:`dev`
|
||||
- 测试数据库:`test`
|
||||
## IDEA 中切换
|
||||
|
||||
也可以使用启动参数:
|
||||
1. 在 Maven 面板的 `Profiles` 中,`local`、`dev`、`test` 只勾选一个。
|
||||
2. 重新加载 Maven 项目。
|
||||
3. 在目标服务的 Maven `Plugins > spring-boot > spring-boot:run` 启动。
|
||||
|
||||
```text
|
||||
--spring.profiles.active=test
|
||||
例如海外查询链路需要分别启动 `gateway-overseas`、`lastleg-service` 和
|
||||
`warehouse-overseas-service`。已经运行的进程不会自动切换环境,切换 Profile
|
||||
后必须停止并重新启动。
|
||||
|
||||
IDEA 普通绿色 Run 按钮不执行 Maven,因此不会读取 Maven Profile。需要继续使用
|
||||
绿色 Run 时,请在 Run Configuration 的 Environment variables 中设置
|
||||
`NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_USERNAME` 和 `NACOS_PASSWORD`。
|
||||
`gateway-overseas` 已默认启用 `overseas` Spring Profile。
|
||||
|
||||
## PowerShell 中切换
|
||||
|
||||
也可以在当前终端加载同一套变量,然后从同一终端启动服务:
|
||||
|
||||
```powershell
|
||||
. .\scripts\use-nacos-environment.ps1 -Environment local -Region overseas
|
||||
. .\scripts\use-nacos-environment.ps1 -Environment dev -Region overseas
|
||||
. .\scripts\use-nacos-environment.ps1 -Environment test -Region overseas
|
||||
```
|
||||
|
||||
## 测试数据库密码
|
||||
脚本读取 `DEV_NACOS_PASSWORD`、`TEST_NACOS_PASSWORD`,namespace 优先读取
|
||||
`*_OVERSEAS_NACOS_NAMESPACE`,未设置时读取 `*_NACOS_NAMESPACE`。命令前面的点和
|
||||
空格不能省略,否则变量只存在于脚本子进程中。
|
||||
|
||||
测试数据库密码不写入 Git。启动 `test` 环境前,在 IDEA 启动配置的环境变量中设置:
|
||||
## 当前限制
|
||||
|
||||
```text
|
||||
TEST_DB_PASSWORD=测试数据库密码
|
||||
```
|
||||
|
||||
公共测试连接参数支持覆盖:
|
||||
|
||||
```text
|
||||
TEST_DB_HOST=8.163.30.128
|
||||
TEST_DB_PORT=3306
|
||||
TEST_DB_USERNAME=root
|
||||
TEST_DB_PASSWORD=测试数据库密码
|
||||
```
|
||||
|
||||
每个服务还支持使用自己的 `*_TEST_DB_URL`、`*_TEST_DB_USERNAME` 和 `*_TEST_DB_PASSWORD` 覆盖公共参数。
|
||||
|
||||
## 分库映射
|
||||
|
||||
| 服务 | 测试数据库 |
|
||||
| --- | --- |
|
||||
| 认证服务 | `baiyue_auth` |
|
||||
| 权限服务 | `baiyue_iam` |
|
||||
| 订单服务 | `baiyue_order` |
|
||||
| 仓库服务 | `baiyue_warehouse` |
|
||||
| 头程履约服务 | `baiyue_firstleg` |
|
||||
| 文件服务 | `baiyue_file` |
|
||||
| 审计服务 | `baiyue_audit` |
|
||||
| 事件桥接服务 | `baiyue_event_bridge` |
|
||||
|
||||
网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `test` 只切换数据库,Nacos 和 Redis 默认仍连接本机。
|
||||
- 环境变量优先级高于配置文件,可用于临时覆盖单个服务。
|
||||
- 修改 IDEA 环境变量后需要重启对应服务。
|
||||
- 不要把测试数据库密码提交到配置文件、启动脚本或 Git。
|
||||
- 测试 Nacos 的 `8848` 客户端端口已确认可连接。
|
||||
- 开发 Nacos 网页控制台可访问,但 `dev-nacos.buggoglobal.com:8848` 当前从本机
|
||||
无法连接。需要开放 Nacos 客户端端口以及 Nacos 2.x/3.x 所需的 gRPC 端口,
|
||||
或提供一个客户端可访问的内部地址。
|
||||
- 国内和海外存在同名 Data ID,远程环境必须使用各自独立 namespace。开发海外
|
||||
namespace 尚未确认,因此通过 `DEV_NACOS_NAMESPACE` 在本机配置。
|
||||
- 不要同时勾选多个环境 Profile;Maven 对同名属性的最终取值不直观,容易连错环境。
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
|---|---|
|
||||
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg |
|
||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
||||
| 人工补投 | `service/EventReplayService.java` |
|
||||
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
||||
| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
||||
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
|
||||
@@ -27,6 +29,8 @@
|
||||
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
|
||||
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
|
||||
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
|
||||
- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。
|
||||
- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Event Bridge 开发阶段可观测性与 SLO 合同
|
||||
|
||||
## 状态与边界
|
||||
|
||||
本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了
|
||||
Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。
|
||||
|
||||
指标标签只允许 `target_region`、`event_type`、`result`:
|
||||
|
||||
- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。
|
||||
- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。
|
||||
- `result` 仅为代码中冻结的接收或传输结果枚举。
|
||||
- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。
|
||||
|
||||
## 指标合同
|
||||
|
||||
| Prometheus 指标 | 含义 |
|
||||
|---|---|
|
||||
| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` |
|
||||
| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` |
|
||||
| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 |
|
||||
| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 |
|
||||
| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 |
|
||||
| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 |
|
||||
|
||||
`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数,
|
||||
不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。
|
||||
|
||||
Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge,
|
||||
不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有
|
||||
`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。
|
||||
|
||||
## 开发阶段 SLI 与 SLO
|
||||
|
||||
SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT`
|
||||
不重复进入分母。滚动 30 天目标:
|
||||
|
||||
- 99% 的新接收跨区事件在 60 秒内得到下游确认。
|
||||
- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。
|
||||
|
||||
代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组:
|
||||
|
||||
```promql
|
||||
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d]))
|
||||
/
|
||||
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99
|
||||
```
|
||||
|
||||
```promql
|
||||
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d]))
|
||||
/
|
||||
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999
|
||||
```
|
||||
|
||||
## 告警合同
|
||||
|
||||
规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同:
|
||||
|
||||
- 新增 DEAD:下一次评估立即告警,不设置持续等待。
|
||||
`sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0`
|
||||
- 最老待处理年龄预警:任一有限标签序列超过 120 秒。
|
||||
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120`
|
||||
- 最老待处理年龄严重:任一有限标签序列超过 900 秒。
|
||||
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900`
|
||||
- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。
|
||||
|
||||
告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Event Bridge Trace 传播基线
|
||||
|
||||
- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。
|
||||
- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。
|
||||
- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。
|
||||
- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。
|
||||
- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。
|
||||
- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id`
|
||||
关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。
|
||||
|
||||
## 共享契约缺口
|
||||
|
||||
当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能
|
||||
无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由
|
||||
`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享
|
||||
Payload、信封字段、哈希规则或可靠性状态机。
|
||||
+2
-1
@@ -51,7 +51,8 @@ public class EventBridgeController {
|
||||
return ApiResponse.success(eventInboxService.receive(envelope));
|
||||
}
|
||||
|
||||
@Operation(summary = "人工补投死信事件")
|
||||
@Operation(summary = "人工补投死信事件",
|
||||
description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因")
|
||||
@PostMapping("/events/{eventId}/replay")
|
||||
public ApiResponse<Void> replay(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
+18
@@ -2,6 +2,7 @@ package com.baiyue.eventbridge.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||
WHERE event_id=#{eventId} AND status='DEAD'
|
||||
""")
|
||||
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
|
||||
|
||||
/**
|
||||
* Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume;
|
||||
* Prometheus collection never invokes this query directly.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT target_region AS targetRegion, event_type AS eventType,
|
||||
SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount,
|
||||
COALESCE(TIMESTAMPDIFF(MICROSECOND,
|
||||
MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END),
|
||||
CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds,
|
||||
SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount
|
||||
FROM bridge_inbox
|
||||
WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')
|
||||
GROUP BY target_region, event_type
|
||||
""")
|
||||
List<InboxMetricsSnapshot> observabilitySnapshots();
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.MultiGauge;
|
||||
import io.micrometer.core.instrument.Tags;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class EventBridgeInboxMetricsSampler {
|
||||
public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog";
|
||||
public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds";
|
||||
public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead";
|
||||
|
||||
private final BridgeInboxMapper inboxMapper;
|
||||
private final MultiGauge backlog;
|
||||
private final MultiGauge oldestAge;
|
||||
private final MultiGauge dead;
|
||||
|
||||
public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) {
|
||||
this.inboxMapper = inboxMapper;
|
||||
this.backlog = MultiGauge.builder(BACKLOG_METRIC)
|
||||
.description("Events waiting for downstream acknowledgement").register(registry);
|
||||
this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC)
|
||||
.description("Age in seconds of the oldest pending Inbox event").register(registry);
|
||||
this.dead = MultiGauge.builder(DEAD_METRIC)
|
||||
.description("Current number of DEAD Inbox events").register(registry);
|
||||
}
|
||||
|
||||
@Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}",
|
||||
fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}")
|
||||
public void sample() {
|
||||
final List<InboxMetricsSnapshot> snapshots;
|
||||
try {
|
||||
snapshots = inboxMapper.observabilitySnapshots();
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception);
|
||||
return;
|
||||
}
|
||||
Map<MetricKey, Aggregate> aggregates = aggregate(snapshots);
|
||||
backlog.register(rows(aggregates, MetricValue.BACKLOG), true);
|
||||
oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true);
|
||||
dead.register(rows(aggregates, MetricValue.DEAD), true);
|
||||
}
|
||||
|
||||
private Map<MetricKey, Aggregate> aggregate(List<InboxMetricsSnapshot> snapshots) {
|
||||
Map<MetricKey, Aggregate> result = new LinkedHashMap<>();
|
||||
if (snapshots == null) {
|
||||
return result;
|
||||
}
|
||||
for (InboxMetricsSnapshot snapshot : snapshots) {
|
||||
MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()),
|
||||
EventBridgeMetrics.eventType(snapshot.getEventType()));
|
||||
result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<MultiGauge.Row<?>> rows(Map<MetricKey, Aggregate> aggregates, MetricValue value) {
|
||||
List<MultiGauge.Row<?>> rows = new ArrayList<>();
|
||||
aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of(
|
||||
Tags.of("target_region", key.targetRegion, "event_type", key.eventType),
|
||||
value.value(aggregate))));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private record MetricKey(String targetRegion, String eventType) { }
|
||||
|
||||
private static final class Aggregate {
|
||||
private long backlog;
|
||||
private double oldestAge;
|
||||
private long dead;
|
||||
|
||||
void add(InboxMetricsSnapshot snapshot) {
|
||||
backlog += snapshot.getBacklogCount();
|
||||
oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds());
|
||||
dead += snapshot.getDeadCount();
|
||||
}
|
||||
}
|
||||
|
||||
private enum MetricValue {
|
||||
BACKLOG {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.backlog; }
|
||||
},
|
||||
OLDEST_AGE {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.oldestAge; }
|
||||
},
|
||||
DEAD {
|
||||
@Override Number value(Aggregate aggregate) { return aggregate.dead; }
|
||||
};
|
||||
|
||||
abstract Number value(Aggregate aggregate);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** Low-cardinality transport metrics for the cross-region event bridge. */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class EventBridgeMetrics {
|
||||
public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive";
|
||||
public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport";
|
||||
public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency";
|
||||
|
||||
private static final Set<String> KNOWN_EVENT_TYPES = Set.of(
|
||||
EventTypes.ORDER_APPROVED,
|
||||
EventTypes.ORDER_CANCELLED,
|
||||
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
|
||||
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
|
||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
|
||||
EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED,
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
|
||||
EventTypes.LAST_MILE_HANDOFF_ACCEPTED,
|
||||
EventTypes.LAST_MILE_HANDOFF_REJECTED,
|
||||
EventTypes.LAST_MILE_MILESTONE_CHANGED,
|
||||
EventTypes.LAST_MILE_COMPLETED,
|
||||
EventTypes.TRACKING_MILESTONE_PROJECTED);
|
||||
|
||||
private final MeterRegistry registry;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public EventBridgeMetrics(MeterRegistry registry) {
|
||||
this(registry, Clock.systemDefaultZone());
|
||||
}
|
||||
|
||||
EventBridgeMetrics(MeterRegistry registry, Clock clock) {
|
||||
this.registry = registry;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public void recordReceive(EventEnvelope envelope, ReceiveResult result) {
|
||||
try {
|
||||
registry.counter(RECEIVE_METRIC,
|
||||
"target_region", targetRegion(envelope.targetRegion().name()),
|
||||
"event_type", eventType(envelope.eventType()),
|
||||
"result", result.name()).increment();
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void recordTransport(BridgeInbox event, TransportResult result) {
|
||||
try {
|
||||
String targetRegion = targetRegion(event.getTargetRegion());
|
||||
String eventType = eventType(event.getEventType());
|
||||
registry.counter(TRANSPORT_METRIC,
|
||||
"target_region", targetRegion,
|
||||
"event_type", eventType,
|
||||
"result", result.name()).increment();
|
||||
if (result.acknowledged() && event.getReceivedAt() != null) {
|
||||
Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock));
|
||||
if (latency.isNegative()) {
|
||||
latency = Duration.ZERO;
|
||||
}
|
||||
Timer.builder(DELIVERY_LATENCY_METRIC)
|
||||
.description("Time from Event Bridge receipt to downstream acknowledgement")
|
||||
.serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15))
|
||||
.tags("target_region", targetRegion, "event_type", eventType)
|
||||
.register(registry).record(latency);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static String targetRegion(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER";
|
||||
}
|
||||
|
||||
public static String eventType(String value) {
|
||||
return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER";
|
||||
}
|
||||
|
||||
public enum ReceiveResult {
|
||||
RECEIVED,
|
||||
DUPLICATE,
|
||||
CONFLICT
|
||||
}
|
||||
|
||||
public enum TransportResult {
|
||||
DELIVERED(true),
|
||||
RETRY(false),
|
||||
DEAD(false),
|
||||
ACKNOWLEDGED_LEASE_LOST(true),
|
||||
FAILED_LEASE_LOST(false),
|
||||
MANUAL_REPLAY(false);
|
||||
|
||||
private final boolean acknowledged;
|
||||
|
||||
TransportResult(boolean acknowledged) {
|
||||
this.acknowledged = acknowledged;
|
||||
}
|
||||
|
||||
boolean acknowledged() {
|
||||
return acknowledged;
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.eventbridge.metrics;
|
||||
|
||||
/** Result row of the bounded, scheduled Inbox observability aggregation. */
|
||||
public class InboxMetricsSnapshot {
|
||||
private String targetRegion;
|
||||
private String eventType;
|
||||
private long backlogCount;
|
||||
private double oldestPendingAgeSeconds;
|
||||
private long deadCount;
|
||||
|
||||
public String getTargetRegion() {
|
||||
return targetRegion;
|
||||
}
|
||||
|
||||
public void setTargetRegion(String targetRegion) {
|
||||
this.targetRegion = targetRegion;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public void setEventType(String eventType) {
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
public long getBacklogCount() {
|
||||
return backlogCount;
|
||||
}
|
||||
|
||||
public void setBacklogCount(long backlogCount) {
|
||||
this.backlogCount = backlogCount;
|
||||
}
|
||||
|
||||
public double getOldestPendingAgeSeconds() {
|
||||
return oldestPendingAgeSeconds;
|
||||
}
|
||||
|
||||
public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) {
|
||||
this.oldestPendingAgeSeconds = oldestPendingAgeSeconds;
|
||||
}
|
||||
|
||||
public long getDeadCount() {
|
||||
return deadCount;
|
||||
}
|
||||
|
||||
public void setDeadCount(long deadCount) {
|
||||
this.deadCount = deadCount;
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -5,6 +5,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||
import com.baiyue.eventbridge.metrics.EventBridgeMetrics;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -13,7 +14,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@@ -23,14 +26,19 @@ import java.util.Objects;
|
||||
public class EventInboxService {
|
||||
@Resource private InboxStore inboxStore;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private EventBridgeMetrics metrics;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
private String localRegion;
|
||||
|
||||
/** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */
|
||||
@Value("${baiyue.event-bridge.accept-any-region:false}")
|
||||
private boolean acceptAnyRegion;
|
||||
|
||||
@Transactional
|
||||
public EventReceiveResult receive(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
|
||||
if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
|
||||
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
|
||||
}
|
||||
if (!EventPayloadHash.matches(
|
||||
@@ -44,6 +52,7 @@ public class EventInboxService {
|
||||
BridgeInbox inbox = toInbox(envelope);
|
||||
try {
|
||||
inboxStore.insert(inbox);
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED);
|
||||
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
|
||||
} catch (DuplicateKeyException exception) {
|
||||
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
|
||||
@@ -56,8 +65,10 @@ public class EventInboxService {
|
||||
|
||||
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!sameEnvelope(existing, envelope)) {
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
|
||||
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
@@ -73,7 +84,7 @@ public class EventInboxService {
|
||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
||||
&& Objects.equals(existing.getOccurredAt(), envelope.occurredAt())
|
||||
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
|
||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||
@@ -95,7 +106,7 @@ public class EventInboxService {
|
||||
inbox.setTraceId(envelope.traceId());
|
||||
inbox.setCorrelationId(envelope.correlationId());
|
||||
inbox.setCausationId(envelope.causationId());
|
||||
inbox.setOccurredAt(envelope.occurredAt());
|
||||
inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt()));
|
||||
inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT));
|
||||
try {
|
||||
inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload()));
|
||||
@@ -106,4 +117,8 @@ public class EventInboxService {
|
||||
inbox.setReceivedAt(LocalDateTime.now());
|
||||
return inbox;
|
||||
}
|
||||
|
||||
private static Instant normalizeOccurredAt(Instant value) {
|
||||
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user