Compare commits

...
244 Commits
Author SHA1 Message Date
liuyihe 5c331f173d 提交 2026-08-24 17:58:47 +08:00
liuyihe 01675b8220 提交 2026-08-24 17:12:30 +08:00
liuyihe ce9f08c756 提交 2026-08-24 14:52:50 +08:00
liuyihe 6a86dbcb37 Merge remote-tracking branch 'origin/dev-lyhfive' into dev-lyhfive 2026-08-24 13:41:54 +08:00
ouyangxqandliuyihe b72dd54b80 fix container customer snapshot schema migration 2026-08-24 13:36:38 +08:00
ouyangxq 287591c75f fix container customer snapshot schema migration 2026-08-24 11:19:33 +08:00
ouyangxq fe4eb0b593 perf(ci): allow two concurrent non-production deploys 2026-08-24 11:17:17 +08:00
ouyangxq 9198ce02d9 fix(ci): isolate Docker registry credentials 2026-08-24 11:00:03 +08:00
ouyangxq a4a693837e perf(ci): acquire deploy lock before target agent 2026-08-24 10:56:33 +08:00
ouyangxq c8574326dd fix smart loading candidate result mapping 2026-08-24 10:51:49 +08:00
ouyangxq 938c5771b3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	Jenkinsfile
#	Jenkinsfile.service
#	deploy/scripts/deploy-built-service.sh
#	scripts/test-environment-pipeline-contract.test.mjs
2026-08-24 10:49:08 +08:00
ouyangxq 0fbba13714 优化 2026-08-24 10:35:40 +08:00
ouyangxq ccfd024aef fix phase3 phase4 development acceptance blockers 2026-08-24 10:33:05 +08:00
ouyangxq 81899015cf 优化 2026-08-24 09:29:26 +08:00
ouyangxq ae9238b3cf feat(ci): rollback unhealthy deployments 2026-08-22 18:26:47 +08:00
ouyangxq 657f3553d8 fix(ci): retain service source workspaces 2026-08-22 18:01:20 +08:00
ouyangxq abab08e879 perf(ci): reuse Git workspaces across builds 2026-08-22 18:01:15 +08:00
ouyangxq 52b673b878 perf(ci): release coordinator builder executor 2026-08-22 18:01:10 +08:00
ouyangxq d3e16bf29f fix(ci): deploy on target environment agents 2026-08-22 18:01:10 +08:00
ouyangxq 1df87e5d17 fix(ci): retain service source workspaces 2026-08-22 17:55:50 +08:00
ouyangxq 46e5c14b86 perf(ci): reuse Git workspaces across builds 2026-08-22 17:53:18 +08:00
ouyangxq 309900de82 perf(ci): release coordinator builder executor 2026-08-22 17:46:11 +08:00
ouyangxq 232c2657f1 fix(ci): deploy on target environment agents 2026-08-22 17:43:20 +08:00
liuyihe e398f1f7e0 修改提交 2026-08-22 12:35:32 +08:00
ouyangxq e92cfe18ad ci: use internal Java base image 2026-08-22 12:22:04 +08:00
ouyangxq 996102e77d ci: use internal Java base image 2026-08-22 12:20:26 +08:00
ouyangxq 668668b505 fix(ci): schedule coordinator on builder agents 2026-08-22 12:07:35 +08:00
ouyangxq fd1c582d45 ci: serialize registry-based environment deployments 2026-08-22 12:07:35 +08:00
ouyangxq 0f296e016e ci: use dedicated registry credentials 2026-08-22 12:07:35 +08:00
ouyangxq a7ec6ed285 ci: publish development and test images to registry 2026-08-22 12:07:35 +08:00
ouyangxq 0d3a27f4ba build(docker): layer Spring Boot service images 2026-08-22 12:07:35 +08:00
ouyangxq dc131f26af Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 11:47:12 +08:00
ouyangxq 775b2c4863 优化 2026-08-22 11:46:41 +08:00
ouyangxq cee3ee07a8 fix(ci): schedule coordinator on builder agents 2026-08-22 11:45:14 +08:00
ouyangxq 5a78e9ca54 ci: default development coordinator to dev jobs 2026-08-22 11:14:28 +08:00
ouyangxq aa80ff4b2e fix(ci): use compatible Jenkins job copy API 2026-08-22 11:13:09 +08:00
ouyangxq 7aebc3c0cb ci: add safe dev job provisioning script 2026-08-22 11:08:07 +08:00
ouyangxq ba8a6b7832 ci: serialize registry-based environment deployments 2026-08-22 11:01:06 +08:00
ouyangxq ff9c1e2bc1 ci: use dedicated registry credentials 2026-08-22 10:50:33 +08:00
ouyangxq 3b3d672fb1 ci: publish development and test images to registry 2026-08-22 10:38:40 +08:00
ouyangxq b25087e60b build(docker): layer Spring Boot service images 2026-08-22 10:27:28 +08:00
ouyangxq 85173b608d Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 10:20:39 +08:00
ouyangxq 1f2c4cd8bb 优化 2026-08-22 10:20:38 +08:00
liuyihe 93feb7da67 修改提交 2026-08-22 10:16:45 +08:00
ouyangxq 76c3662dda 优化 2026-08-22 10:02:10 +08:00
ouyangxq af8724dd70 优化 2026-08-22 09:56:26 +08:00
liuyihe ab2f3ed72e Merge remote-tracking branch 'origin/dev' into dev 2026-08-22 09:52:04 +08:00
liuyihe 15636a1600 修改提交 2026-08-22 09:51:41 +08:00
ouyangxq ff926d5b74 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 09:51:03 +08:00
ouyangxq 988d1bee7c 1 2026-08-22 09:51:01 +08:00
liuyihe 76b41c0812 Merge remote-tracking branch 'origin/dev' into dev 2026-08-21 18:12:25 +08:00
liuyihe 4f14367eba 修改提交 2026-08-21 18:12:07 +08:00
ouyangxq 1a67b3404d Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 17:59:21 +08:00
ouyangxq 72b050af6d Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 17:58:55 +08:00
ouyangxq 24e4a402a9 优化 2026-08-21 17:58:52 +08:00
liuyihe d1d269e96f 修改提交 2026-08-21 17:38:14 +08:00
liuyihe bbd303cd65 修改提交 2026-08-21 17:21:26 +08:00
ouyangxq af97217184 优化 2026-08-21 17:19:49 +08:00
ouyangxq 598749d8e4 优化 2026-08-21 17:07:34 +08:00
ouyangxq 8c280e19c4 优化 2026-08-21 17:05:52 +08:00
ouyangxq cd477fe40a 优化 2026-08-21 16:15:02 +08:00
ouyangxq 8e07342fa1 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 16:04:19 +08:00
ouyangxq 0e7df9052a 优化 2026-08-21 16:04:16 +08:00
liuyihe e534784052 提交 2026-08-21 14:42:40 +08:00
liuyihe 5fd00d3151 提交 2026-08-21 11:43:03 +08:00
ouyangxq dceec6cbed 优化 2026-08-21 10:56:51 +08:00
liuyihe e17f03e596 提交 2026-08-21 10:43:25 +08:00
ouyangxq d5e9ba6d4c 优化 2026-08-21 10:38:28 +08:00
ouyangxq 361e6be3be 优化 2026-08-21 09:31:29 +08:00
ouyangxq 96feb8faff 优化 2026-08-20 20:54:30 +08:00
ouyangxq acc5bec3b1 优化 2026-08-20 20:38:46 +08:00
ouyangxq 5400a8e8e7 优化 2026-08-20 20:29:51 +08:00
ouyangxq 11cfba880d 优化 2026-08-20 20:14:21 +08:00
ouyangxq 893d1801b5 优化 2026-08-20 19:55:21 +08:00
ouyangxq d170505327 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 19:11:43 +08:00
ouyangxq 34fb91baf6 优化 2026-08-20 19:10:16 +08:00
ouyangxq 8095a99afa Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	scripts/admin-test-env-modules/orders.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
2026-08-20 18:24:23 +08:00
ouyangxq b0dcbf77e5 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 18:22:20 +08:00
ouyangxq 4256e74c11 优化 2026-08-20 18:21:04 +08:00
ouyangxq 12604afa36 优化 2026-08-20 18:17:32 +08:00
ouyangxq 382ffc7f55 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	config/nacos/cn/event-bridge-service.yml
#	config/nacos/overseas/lastleg-service.yml
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/resources/application.yml
#	iam/src/main/resources/db/lastleg-permissions.sql
#	lastleg-service/AGENTS.md
#	lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
#	lastleg-service/src/main/resources/application.yml
#	lastleg-service/src/main/resources/db/lastleg.sql
#	scripts/db/incremental.sql
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
2026-08-20 18:14:55 +08:00
liuyihe a38396ce3f 提交 2026-08-20 18:06:07 +08:00
ouyangxq d202ca85ef 优化 2026-08-20 18:05:24 +08:00
ouyangxq 1cd8b6a473 优化 2026-08-20 18:02:38 +08:00
ouyangxq b1655a1841 优化 2026-08-20 18:02:20 +08:00
ouyangxq 1e8577dc4e 低碳经济 2026-08-20 17:07:51 +08:00
ouyangxq 5b9ae25f29 优化 2026-08-20 17:00:05 +08:00
liuyihe c9a05b108b 桥接事件 2026-08-20 16:45:47 +08:00
ouyangxq 09f6e28c1f 优化 2026-08-20 16:13:51 +08:00
liuyihe 1f0c0f7751 提交 2026-08-20 15:59:45 +08:00
liuyihe 2847ad1b2d 提交 2026-08-20 15:58:25 +08:00
liuyihe cd34cd426e 提交 2026-08-20 15:25:40 +08:00
liuyihe b55b049e77 优化 2026-08-20 15:20:05 +08:00
ouyangxq 6c7a670f1e 优化 2026-08-20 14:43:07 +08:00
ouyangxq 50d9803566 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 13:49:08 +08:00
ouyangxq 164d862588 优化 2026-08-20 13:42:55 +08:00
liuyihe 802893b2ff Merge remote-tracking branch 'origin/dev' into dev 2026-08-20 13:34:55 +08:00
liuyihe b139255d3b 入库修改 2026-08-20 13:34:34 +08:00
ouyangxq d3183d8f2c 优化 2026-08-20 13:34:29 +08:00
ouyangxq 3e517ee74e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 13:34:04 +08:00
ouyangxq d340c418e8 优化 2026-08-20 13:07:40 +08:00
ouyangxq 3b19a1bc16 Merge branch 'test' into dev 2026-08-20 13:00:49 +08:00
ouyangxq 15d3ffbc0a 优化 2026-08-20 13:00:39 +08:00
ouyangxq b060be18a9 Merge branch 'test' into dev 2026-08-20 12:50:05 +08:00
ouyangxq b029f4ce28 优化 2026-08-20 12:49:54 +08:00
ouyangxq fd1da73fa8 Merge branch 'test' into dev 2026-08-20 12:34:49 +08:00
ouyangxq c83620a86a 优化 2026-08-20 12:34:10 +08:00
ouyangxq b464de1edb Merge branch 'test' into dev 2026-08-20 10:39:13 +08:00
ouyangxq 3d57abf260 优化 2026-08-20 10:37:32 +08:00
ouyangxq 0384f54da7 Merge branch 'test' into dev 2026-08-20 10:28:57 +08:00
ouyangxq bdf545ee0a 优化 2026-08-20 10:28:45 +08:00
ouyangxq 0575ea247f Merge branch 'test' into dev 2026-08-20 10:23:00 +08:00
ouyangxq 7a5719b5a1 优化 2026-08-20 10:22:51 +08:00
ouyangxq d36e480413 优化 2026-08-20 10:17:39 +08:00
ouyangxq e469749a98 Merge branch 'test' into dev 2026-08-20 09:50:48 +08:00
ouyangxq be5d89a4e6 优化 2026-08-20 09:49:28 +08:00
ouyangxq 7cf4a8d562 优化 2026-08-20 09:43:31 +08:00
ouyangxq cf2356bccb Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 09:10:32 +08:00
ouyangxq f227cf05a3 优化 2026-08-20 09:10:30 +08:00
liuyihe f03ea227ed 暂存提交 2026-08-19 17:23:54 +08:00
liuyihe 26eba227eb 暂存提交 2026-08-19 17:22:03 +08:00
ouyangxq 064247a8c9 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 17:08:42 +08:00
ouyangxq f78b44297d 优化 2026-08-19 17:08:41 +08:00
liuyihe 88100f3536 Merge remote-tracking branch 'origin/dev' into dev 2026-08-19 16:49:58 +08:00
liuyihe 63618c20dc 暂存提交 2026-08-19 16:49:41 +08:00
ouyangxq a4ec4e8e99 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 16:45:45 +08:00
ouyangxq 3b8934fc60 优化 2026-08-19 16:45:44 +08:00
liuyihe 6acb6178da 暂存提交 2026-08-19 16:24:02 +08:00
ouyangxq f5c4bf130a Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:14:04 +08:00
ouyangxq f25e270f22 优化 2026-08-19 14:13:47 +08:00
ouyangxq 000fd10b6a fix(sql): complete IAM security event comments 2026-08-19 14:04:16 +08:00
ouyangxq 23e78bb33f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:02:11 +08:00
ouyangxq 41d70841fc 优化 2026-08-19 09:58:43 +08:00
ouyangxq 008d7ba574 优化 2026-08-19 09:57:50 +08:00
ouyangxq b908d9f248 优化 2026-08-19 09:56:17 +08:00
ouyangxq 80527fa15f 优化 2026-08-19 09:55:29 +08:00
ouyangxq a381d443da 优化 2026-08-19 09:44:18 +08:00
ouyangxq b5864bfcda 优化 2026-08-19 09:42:07 +08:00
ouyangxq ff1d330c2d 优化 2026-08-19 09:14:30 +08:00
ouyangxq 8845a66749 优化 2026-08-18 19:04:22 +08:00
ouyangxq 688e5649b0 优化 2026-08-18 17:40:32 +08:00
ouyangxq d70c08a378 优化 2026-08-18 16:42:01 +08:00
ouyangxq a9061eca54 优化 2026-08-18 16:39:39 +08:00
ouyangxq 7fe5b4e31f 优化 2026-08-18 16:10:44 +08:00
ouyangxq fa77170304 优化 2026-08-18 16:09:52 +08:00
ouyangxq 82ee80226e 优化 2026-08-18 16:03:42 +08:00
ouyangxq 12e9f3746e 优化 2026-08-18 15:49:52 +08:00
ouyangxq 6d5ec5759b 优化 2026-08-18 15:49:37 +08:00
ouyangxq 9db371ab0e 伦 2026-08-18 15:47:44 +08:00
ouyangxq 81e6610eb8 傣给 2026-08-18 15:46:56 +08:00
ouyangxq b26e64bb8d 优化 2026-08-18 15:46:52 +08:00
ouyangxq 4b0b3e9172 优化 2026-08-18 15:43:58 +08:00
ouyangxq 4e4d047862 优化 2026-08-18 11:46:10 +08:00
ouyangxq cad9a81d21 优化 2026-08-18 11:44:39 +08:00
ouyangxq ec4b1e0fe5 优化 2026-08-18 11:41:10 +08:00
ouyangxq 3e1010fa26 优化 2026-08-18 11:32:55 +08:00
ouyangxq ea26eecc50 优化 2026-08-18 09:09:28 +08:00
ouyangxq 092c003e3a 优化 2026-08-17 20:21:44 +08:00
ouyangxq b8c7f9fea2 优化 2026-08-17 20:01:48 +08:00
ouyangxq 45b51415cf 优化 2026-08-17 19:51:54 +08:00
ouyangxq cddd23e4c7 优化 2026-08-17 19:13:08 +08:00
ouyangxq 5199146e97 优化 2026-08-17 18:32:38 +08:00
ouyangxq 5c22018edd 优化 2026-08-17 18:04:04 +08:00
ouyangxq fd4afd6ae6 优化 2026-08-17 18:00:38 +08:00
ouyangxq 7c66097672 优化 2026-08-17 17:49:03 +08:00
ouyangxq c71db4b481 优化 2026-08-17 16:45:43 +08:00
ouyangxq 592da4d7e2 优化 2026-08-17 16:17:20 +08:00
ouyangxq 75580c3585 优化 2026-08-17 16:07:20 +08:00
ouyangxq 68d9216cb8 优化 2026-08-17 15:55:11 +08:00
ouyangxq 5f79616623 优化 2026-08-17 15:41:42 +08:00
ouyangxq 4abcb2b80f 优化 2026-08-17 14:50:00 +08:00
ouyangxq a34b5d5ba0 优化 2026-08-17 14:46:44 +08:00
ouyangxq b914bb7532 优化 2026-08-17 13:47:20 +08:00
ouyangxq 6739c83467 优化 2026-08-17 13:24:48 +08:00
ouyangxq ac6cf9c904 优化 2026-08-17 12:31:24 +08:00
ouyangxq 8566c6ea9e 优化 2026-08-17 11:23:35 +08:00
ouyangxq 6b416621bb 优化 2026-08-17 11:23:28 +08:00
ouyangxq d51e054def 优化 2026-08-17 10:54:08 +08:00
ouyangxq 7a9c72a6a2 优化 2026-08-17 09:41:08 +08:00
ouyangxq 20d836ab3d test: preserve fee item identifiers 2026-08-15 18:07:23 +08:00
ouyangxq f2a49d032a test: map finance supplier category from fee type 2026-08-15 18:03:47 +08:00
ouyangxq 2cec065582 test: provision finance fixture prerequisites 2026-08-15 17:58:15 +08:00
ouyangxq 0e2a291e7a test: use active suppliers for finance fixtures 2026-08-15 17:52:46 +08:00
ouyangxq 5e1e49085d test: normalize finance container allocation sources 2026-08-15 17:45:51 +08:00
ouyangxq 28daf15082 test: confirm plans before PDA acceptance 2026-08-15 17:40:01 +08:00
ouyangxq 05921eca13 test: stabilize preload push replay assertions 2026-08-15 17:32:16 +08:00
ouyangxq 8350bcdf00 fix: replay preload confirmation idempotently 2026-08-15 17:27:26 +08:00
ouyangxq a35794e918 feat: close preload confirmation and loading flow 2026-08-15 17:04:00 +08:00
ouyangxq 5b8e7d6989 fix: harden smart loading push idempotency 2026-08-15 12:39:30 +08:00
ouyangxq 0ddffeddc8 优化 2026-08-15 12:33:23 +08:00
ouyangxq 3a9558bd1c 优化 2026-08-15 12:12:34 +08:00
ouyangxq 08187363a1 优化 2026-08-15 11:51:15 +08:00
ouyangxq 46cb95db15 优化 2026-08-15 11:36:53 +08:00
ouyangxq 282eb73371 优化 2026-08-15 11:20:45 +08:00
ouyangxq 0f915d7b24 优化 2026-08-15 11:19:10 +08:00
ouyangxq c544b1bc39 优化 2026-08-15 11:19:08 +08:00
ouyangxq 792965c822 优化 2026-08-15 11:14:57 +08:00
ouyangxq dfa3e8cc53 优化 2026-08-15 11:14:50 +08:00
ouyangxq fdea2a6f31 优化 2026-08-15 11:14:47 +08:00
ouyangxq c4adba57db 优化 2026-08-15 11:12:19 +08:00
ouyangxq 557ab75319 优化 2026-08-15 11:05:58 +08:00
ouyangxq c4282020b5 优化 2026-08-15 10:35:54 +08:00
ouyangxq 555acca779 优化 2026-08-15 10:32:39 +08:00
ouyangxq 9e7cc55eab 优化 2026-08-15 10:27:15 +08:00
ouyangxq c6d7fb0d84 优化 2026-08-15 10:09:04 +08:00
ouyangxq 233f8f312d 优化 2026-08-15 10:05:03 +08:00
ouyangxq bafabf7217 优化 2026-08-15 09:57:46 +08:00
ouyangxq e7c0788a0b 优化 2026-08-15 09:49:13 +08:00
ouyangxq c07af65c3b 优化 2026-08-15 09:49:09 +08:00
ouyangxq 2bf3ff9d8e 优化 2026-08-15 09:49:00 +08:00
ouyangxq 20907e4685 优化 2026-08-15 09:47:17 +08:00
ouyangxq 1982a7dd69 test: align ocean booking fixtures with direct confirmed flow 2026-08-15 09:35:52 +08:00
ouyangxq e4d5e6d631 test: release split bookings before pda 2026-08-14 23:21:47 +08:00
ouyangxq 5bb8f4db6d test: align manual booking list dependencies 2026-08-14 23:14:11 +08:00
ouyangxq 32e9b69556 test(finance): complete automatic fee fixture workflow 2026-08-14 23:03:36 +08:00
ouyangxq 1db7c3b3cf test: align customer archive acceptance status 2026-08-14 22:59:57 +08:00
ouyangxq b89f14217d test: bind real containers before pda push 2026-08-14 22:59:10 +08:00
ouyangxq 0c845874f4 test: distinguish active and void overview photos 2026-08-14 22:43:07 +08:00
ouyangxq 011c30cc09 test: align container flow acceptance identities 2026-08-14 22:30:28 +08:00
ouyangxq 8aa58a4f72 fix(firstleg): hide void photo download paths 2026-08-14 22:29:50 +08:00
ouyangxq b2a093b31b test: release bookings before opening PDA 2026-08-14 22:21:24 +08:00
ouyangxq 010098c4af fix(firstleg): require real container before pda push 2026-08-14 22:03:21 +08:00
ouyangxq fdd011bc57 test: correct container loading acceptance order 2026-08-14 21:45:54 +08:00
ouyangxq dbca4a1403 test: stop container acceptance after failed booking link 2026-08-14 21:37:02 +08:00
ouyangxq 9bb046063c fix(firstleg): execute container customer scope query 2026-08-14 21:31:46 +08:00
ouyangxq 7cb5da753e fix(firstleg): keep truck links on container ownership 2026-08-14 21:14:42 +08:00
ouyangxq a4a4374c67 test(firstleg): cover simulated container booking links 2026-08-14 21:00:20 +08:00
ouyangxq 7184f5083a feat(firstleg): link simulated containers to real bookings 2026-08-14 20:59:03 +08:00
ouyangxq 993b3dfe03 优化 2026-08-14 19:59:26 +08:00
ouyangxq 7d4256dbd2 优化 2026-08-14 19:09:51 +08:00
ouyangxq 10d3390bb1 优化 2026-08-14 18:50:07 +08:00
ouyangxq 9b36978448 修复开发部署 SSH 指纹读取 2026-08-14 17:54:53 +08:00
ouyangxq e33b2c8645 优化 2026-08-14 17:53:09 +08:00
ouyangxq 2752cfb5b2 支持开发环境远程部署 2026-08-14 17:45:43 +08:00
ouyangxq feced8eb92 优化 2026-08-14 17:37:59 +08:00
ouyangxq 87e919f3c4 优化 2026-08-14 16:06:27 +08:00
ouyangxq 87eb1aa72f 优化 2026-08-14 14:54:48 +08:00
ouyangxq 36a048a2bc 优化 2026-08-14 14:50:39 +08:00
ouyangxq 261a8bf2df 优化 2026-08-14 14:26:56 +08:00
ouyangxq 06c264db0a 优化 2026-08-14 13:03:09 +08:00
ouyangxq ce6963a25e 优化 2026-08-14 12:50:37 +08:00
ouyangxq 91e781b3ff 优化 2026-08-14 10:57:05 +08:00
ouyangxq 66089f9b99 优化 2026-08-14 10:09:46 +08:00
ouyangxq 0bc752a2be fix: seed IT customer qualifications 2026-08-14 09:46:50 +08:00
1205 changed files with 71832 additions and 6107 deletions
Binary file not shown.
+39
View File
@@ -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();
}
}
}
}
}
+40 -7
View File
@@ -1,16 +1,17 @@
# 国内头程后端项目指引
# 头程与海外仓后端项目指引
## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 不可突破的产品与数据边界
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
@@ -23,8 +24,10 @@
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 分柜、柜次、智能排柜、预装/装柜清单、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` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
@@ -32,26 +35,55 @@
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `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`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` |
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
## 公共模块快速索引
| 关键词 | 模块与入口 |
|---|---|
| 公共依赖版本 | `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` 定位 |
| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
| 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` |
## 全量验收路由
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
## 契约、数据库与验证入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `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`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
@@ -62,6 +94,7 @@
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+49 -27
View File
@@ -1,10 +1,11 @@
def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service'
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
]
def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY',
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG',
@@ -14,6 +15,8 @@ def serviceCheckboxes = [
'file': 'SERVICE_FILE',
'print': 'SERVICE_PRINT',
'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT'
]
@@ -28,9 +31,21 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
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()
@@ -41,6 +56,7 @@ pipeline {
parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
@@ -50,34 +66,41 @@ pipeline {
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-service 时生效;明确选择国内或海外实例')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
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: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像')
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: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
}
stages {
stage('确定构建范围') {
agent { label 'java-builder' }
steps {
script {
String requestedRepository
if (params.ENVIRONMENT == 'test') {
requestedRepository = 'baiyue-firstleg-test'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry'
} else {
requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('预发布/生产环境必须填写真实镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
? 'development'
: params.ENVIRONMENT
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
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()
@@ -147,7 +170,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') {
when {
allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
}
steps {
@@ -158,9 +181,10 @@ pipeline {
stage('测试数据库初始化(可选)') {
when {
allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
}
}
agent { label 'java-test' }
steps {
sh 'sh deploy/scripts/initialize-test-databases.sh'
}
@@ -183,11 +207,12 @@ pipeline {
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
string(name: 'EVENT_BRIDGE_REGION', value: params.EVENT_BRIDGE_REGION)
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
@@ -216,7 +241,4 @@ pipeline {
}
}
post {
always { cleanWs(deleteDirs: true, notFailBuild: true) }
}
}
+176 -101
View File
@@ -1,10 +1,11 @@
def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service'
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
]
def serviceJars = [
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
@@ -14,17 +15,15 @@ def serviceJars = [
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
]
pipeline {
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。
agent any
tools {
maven 'maven-3.8.8'
}
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent none
options {
skipDefaultCheckout(true)
@@ -36,117 +35,167 @@ pipeline {
parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
}
environment {
REGISTRY_CREDENTIALS = 'gitea-test-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
}
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
stage('构建并发布单服务') {
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim()
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim()
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://')
}
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) {
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
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.RESOLVED_SERVICE = service
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
}
sh '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
}
}
}
sh '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
stage('Maven 测试与打包') {
steps {
script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}"
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
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'"
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) {
stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true
}
}
}
}
}
}
stage('Maven 测试与打包') {
stage('部署开发或测试环境') {
when {
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
}
agent none
steps {
script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
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 (params.ENVIRONMENT == 'test') {
echo "测试镜像已保存在当前 Docker 节点:${image}"
} else {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
node(targetLabel) {
deleteDir()
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
sh "docker push ${image}"
unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
withEnv([
'TARGET_IS_LOCAL=true',
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) {
withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
} finally {
sh "docker logout ${registryHost} || true"
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
@@ -155,15 +204,44 @@ pipeline {
}
stage('部署单个服务') {
when { expression { params.DEPLOY } }
when {
beforeAgent true
expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) }
}
agent {
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
}
steps {
deleteDir()
unstash 'deployment-files'
script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' &&
env.NODE_LABELS?.tokenize()?.contains('java-dev')
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 + '"'
withEnv([
"DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}",
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
} finally {
sh "docker logout ${registryHost} || true"
}
}
}
}
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
@@ -171,8 +249,5 @@ pipeline {
success {
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
}
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
@@ -1,8 +1,10 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/
@MapperScan("com.baiyue.audit.mapper")
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
String username,
String appCode,
String clientType,
String clientIp,
String requestId,
String traceId,
String serviceName,
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
@@ -0,0 +1,17 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueAuditApplicationContractTest {
@Test
void importsSharedMybatisPaginationConfiguration() {
Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class);
assertThat(imported).isNotNull();
assertThat(imported.value()).contains(MybatisPlusConfiguration.class);
}
}
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List;
import java.util.Set;
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
verify(service).detail(8L);
}
@Test
void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException {
String expected = "hasAuthority('platform:audit:list')";
assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class,
long.class, long.class, Long.class, Long.class, String.class, String.class, String.class,
Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
}
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData");
}
@Test
void pagePreservesTotalAndPagesForCustomerLogPagination() {
AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null,
null, null, null);
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test
void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L));
+5 -2
View File
@@ -4,6 +4,7 @@
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
@@ -12,7 +13,9 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
| 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` |
@@ -22,7 +25,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -11,6 +11,10 @@ public record TokenResponse(
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn
) {
}
}
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import java.security.SecureRandom;
import java.time.Instant;
@@ -49,6 +50,8 @@ public class AuthService {
private AuthorizationLoader authorizationLoader;
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private final SecureRandom secureRandom = new SecureRandom();
@@ -134,8 +137,13 @@ public class AuthService {
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds());
rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn());
}
@Transactional
@@ -19,12 +19,21 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:",
"platform:overseas-business:",
"platform:overseas-master-data:",
"lastleg:"
);
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties;
@@ -36,21 +45,34 @@ public class OverseasTokenService {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
}
AuthorizationSnapshot authorization = authorizationLoader.load(account);
return issue(account, authorization);
}
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
}
private Set<String> overseasAuthorities(Set<String> permissions) {
if (permissions == null || permissions.isEmpty()) return Set.of();
return permissions.stream()
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith))
.collect(Collectors.toUnmodifiableSet());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
@@ -34,6 +34,7 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
@@ -59,7 +60,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"));
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account);
@@ -67,8 +68,10 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
service.login(new LoginRequest("operator", "secret"));
var response = service.login(new LoginRequest("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
@@ -88,6 +91,42 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(10001L);
account.setDefaultMembershipId(94L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(3);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
var response = service.refresh("refresh-old");
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
@@ -102,7 +141,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
@@ -45,10 +45,13 @@ class OverseasTokenServiceTest {
@Test
void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount();
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
String authority = "platform:overseas-master-data:quote:list";
String lastlegAuthority = "lastleg:pda:operate";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -64,11 +67,13 @@ class OverseasTokenServiceTest {
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
.containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
.containsEntry("membership_id", 84L)
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L)
.containsEntry("roles", Set.of("PLATFORM_ADMIN"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
}
}
@@ -48,7 +48,26 @@ class BusinessAuditAspectTest {
assertThat(event.operatorTenantId()).isEqualTo(10001L);
}
@Test
void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable {
AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
assertThat(event.success()).isFalse();
assertThat(event.errorCode()).isEqualTo("IllegalStateException");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.afterData()).isNull();
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, null);
}
private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, new IllegalStateException("validation failed"));
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable {
AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink);
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
when(joinPoint.proceed()).thenReturn("ok");
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
else when(joinPoint.proceed()).thenThrow(failure);
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
try {
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
} catch (Throwable error) {
if (failure == null) throw error;
}
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture());
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
}
@Test
void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024);
String sanitized = sanitizer.sanitize(Map.of(
"profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"),
"contacts", java.util.List.of(Map.of("emailAddress", "user@example.com"))));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******");
assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******");
assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
+10 -4
View File
@@ -14,12 +14,18 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 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` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
- 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -10,6 +10,7 @@ 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 OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
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";
@@ -0,0 +1,81 @@
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,
List<Order> orders) {
public record 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) {
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) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark,
String palletNo,
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
null, List.of());
}
}
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,13 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */
public record OverseasContainerUnloadedV1(
String sourceContainerBookingId,
String containerNo,
Instant unloadingCompletedAt,
Instant unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt
) {
}
@@ -0,0 +1,57 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
.isEqualTo("仓库备注");
}
@Test
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
@Test
void mapsEachOrdinaryCartonNumberToItsSku() {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
}
}
+1 -1
View File
@@ -18,6 +18,6 @@
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
+5 -3
View File
@@ -9,17 +9,19 @@
| 功能 | 入口 |
|---|---|
| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` |
| 安全链自动配置 | `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` |
| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` |
| 会话与授权来源 | `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` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
## 修改与验证
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
- 所有资源服务统一使用 `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/部署配置和所有实际业务服务。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
+1 -1
View File
@@ -22,6 +22,6 @@
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
@@ -21,6 +21,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -79,6 +81,21 @@ public class GlobalExceptionHandler {
return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(
ResponseStatusException exception, HttpServletRequest request) {
ResultCode code = resultCode(exception.getStatusCode().value());
String message = exception.getReason() == null || exception.getReason().isBlank()
? code.message() : exception.getReason();
return failure(code, message, request);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException exception, HttpServletRequest request) {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -123,4 +140,18 @@ public class GlobalExceptionHandler {
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
}
}
private ResultCode resultCode(int status) {
return switch (status) {
case 400 -> ResultCode.BAD_REQUEST;
case 401 -> ResultCode.UNAUTHORIZED;
case 403 -> ResultCode.FORBIDDEN;
case 404 -> ResultCode.NOT_FOUND;
case 409 -> ResultCode.CONFLICT;
case 422 -> ResultCode.VALIDATION_FAILED;
case 429 -> ResultCode.TOO_MANY_REQUESTS;
case 503 -> ResultCode.SERVICE_UNAVAILABLE;
default -> ResultCode.INTERNAL_ERROR;
};
}
}
@@ -12,7 +12,10 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Method;
@@ -87,6 +90,32 @@ class GlobalExceptionHandlerTest {
.tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0);
}
@Test
void responseStatusConflictMapsToCanonicalConflictInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "stale-version");
var response = handler.handleResponseStatus(
new ResponseStatusException(HttpStatus.CONFLICT, "Order was changed; refresh and retry"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(response.getBody().message()).isEqualTo("Order was changed; refresh and retry");
assertThat(response.getBody().requestId()).isEqualTo("stale-version");
}
@Test
void missingControllerPathMapsToNotFoundInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
var response = handler.handleNoResourceFound(
new NoResourceFoundException(org.springframework.http.HttpMethod.GET,
"api/platform/v1/last-leg/dispatches"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
}
private void paging(long page) {
}
}
+3
View File
@@ -8,3 +8,6 @@ baiyue:
region: ${REGION_CODE:CN}
deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
data-residency: ${DATA_RESIDENCY:CN}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
+83
View File
@@ -21,3 +21,86 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+22
View File
@@ -7,6 +7,28 @@ spring:
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue:
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
sml-automated: ${SML_TRACKING_ENABLED:false}
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
scheduler:
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
lastleg:
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}
outbox-relay:
fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
firstleg:
inbound:
allow-rejected-measurement: false
+36
View File
@@ -21,3 +21,39 @@ baiyue:
max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+4
View File
@@ -8,3 +8,7 @@ spring:
baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+83
View File
@@ -17,4 +17,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+12
View File
@@ -6,6 +6,18 @@ spring.datasource:
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
firstleg:
inbound:
allow-rejected-measurement: false
@@ -15,3 +15,39 @@ baiyue:
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+4
View File
@@ -5,3 +5,7 @@ spring.datasource:
password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+5
View File
@@ -0,0 +1,5 @@
# 海外 Nacos 配置
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${AUDIT_DB_URL}
username: ${AUDIT_DB_USERNAME}
password: ${AUDIT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+62
View File
@@ -0,0 +1,62 @@
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false}
audit.enabled: true
spring:
jackson:
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false
serialization:
write-dates-as-timestamps: false
data:
redis:
host: ${REDIS_HOST:redis-overseas.internal}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
discovery:
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:prod-overseas}
openfeign:
circuitbreaker.enabled: true
client.config.default:
connect-timeout: 3000
read-timeout: 5000
server:
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes.enabled: true
show-details: never
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
call-setters-on-nulls: true
global-config:
banner: false
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,20 @@
spring:
datasource:
url: ${EVENT_BRIDGE_DB_URL}
username: ${EVENT_BRIDGE_DB_USERNAME}
password: ${EVENT_BRIDGE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
region: ${REGION_CODE:OVERSEAS}
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
+13
View File
@@ -0,0 +1,13 @@
spring:
datasource:
url: ${FILE_DB_URL}
username: ${FILE_DB_USERNAME}
password: ${FILE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
file:
storage:
provider: ${FILE_STORAGE_PROVIDER:s3}
endpoint: ${FILE_STORAGE_ENDPOINT:}
bucket: ${FILE_STORAGE_BUCKET:}
access-key: ${FILE_STORAGE_ACCESS_KEY:}
secret-key: ${FILE_STORAGE_SECRET_KEY:}
@@ -0,0 +1,3 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,12 @@
baiyue:
integration:
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:true}
client-id: ${AMAZON_SP_API_CLIENT_ID:}
client-secret: ${AMAZON_SP_API_CLIENT_SECRET:}
refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:}
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
region: ${AMAZON_SP_API_REGION:}
+28
View File
@@ -0,0 +1,28 @@
spring:
datasource:
url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
lastleg:
dispatch-query-api:
enabled: true
smart-dispatch-api:
enabled: true
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
firstleg:
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL}
outbox-relay:
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${PRINT_DB_URL}
username: ${PRINT_DB_USERNAME}
password: ${PRINT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${WAREHOUSE_DB_URL}
username: ${WAREHOUSE_DB_USERNAME}
password: ${WAREHOUSE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+12
View File
@@ -0,0 +1,12 @@
# Auth signs short-lived overseas RS256 tokens with a private JWK Set.
OVERSEAS_JWT_ENABLED=true
OVERSEAS_JWT_ISSUER=auth-service
OVERSEAS_JWT_AUDIENCE=lastleg-api
OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid
OVERSEAS_ACCESS_TOKEN_TTL=15m
OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]}
# Overseas services validate tokens against Auth's public JWKS endpoint.
JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json
JWT_ISSUER=auth-service
JWT_AUDIENCE=lastleg-api
+98
View File
@@ -0,0 +1,98 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
restart: unless-stopped
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
environment: &overseas-environment
SPRING_PROFILES_ACTIVE: overseas
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${NACOS_USERNAME}
NACOS_PASSWORD: ${NACOS_PASSWORD}
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas}
REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal}
REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379}
REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-}
REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
JWT_ISSUER: ${JWT_ISSUER}
JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api}
JWT_JWK_SET_URI: ${JWT_JWK_SET_URI}
REGION_CODE: OVERSEAS
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
DATA_RESIDENCY: OVERSEAS
SPRING_SQL_INIT_MODE: never
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
options:
max-size: 100m
max-file: "5"
services:
gateway-overseas:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9300
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
lastleg-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9303
LASTLEG_DB_URL: ${LASTLEG_DB_URL}
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
warehouse-overseas-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9305
WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
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"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9312
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL}
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events}
networks:
firstleg-overseas-network:
name: firstleg-${DEPLOY_ENV:-test}-overseas-network
infra-network:
external: true
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
+54 -2
View File
@@ -60,6 +60,12 @@ services:
AUTH_DB_URL: ${AUTH_DB_URL}
AUTH_DB_USERNAME: ${AUTH_DB_USERNAME}
AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD}
OVERSEAS_JWT_ENABLED: ${OVERSEAS_JWT_ENABLED:-false}
OVERSEAS_JWT_ISSUER: ${OVERSEAS_JWT_ISSUER:-auth-service}
OVERSEAS_JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api}
OVERSEAS_JWT_RSA_JWK_SET: ${OVERSEAS_JWT_RSA_JWK_SET:-}
OVERSEAS_JWT_CURRENT_KID: ${OVERSEAS_JWT_CURRENT_KID:-}
OVERSEAS_ACCESS_TOKEN_TTL: ${OVERSEAS_ACCESS_TOKEN_TTL:-15m}
iam:
<<: *common
@@ -86,6 +92,19 @@ services:
FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL}
FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME}
FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD}
LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}
MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false}
SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false}
ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false}
COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false}
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai}
CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M}
CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S}
CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20}
CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000}
CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M}
finance:
<<: *common
@@ -180,19 +199,52 @@ services:
AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-}
AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-}
AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-}
MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false}
MATSON_TRACKING_PAGE_URL: ${MATSON_TRACKING_PAGE_URL:-https://www.matson.com/shipment-tracking.html}
MATSON_TRACKING_QUERY_URL: ${MATSON_TRACKING_QUERY_URL:-https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
MATSON_TRACKING_CONNECT_TIMEOUT: ${MATSON_TRACKING_CONNECT_TIMEOUT:-10s}
MATSON_TRACKING_REQUEST_TIMEOUT: ${MATSON_TRACKING_REQUEST_TIMEOUT:-30s}
MATSON_TRACKING_MIN_REQUEST_INTERVAL: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:-5s}
SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false}
SML_TRACKING_QUERY_URL: ${SML_TRACKING_QUERY_URL:-https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
SML_TRACKING_CONNECT_TIMEOUT: ${SML_TRACKING_CONNECT_TIMEOUT:-10s}
SML_TRACKING_REQUEST_TIMEOUT: ${SML_TRACKING_REQUEST_TIMEOUT:-30s}
SML_TRACKING_MIN_REQUEST_INTERVAL: ${SML_TRACKING_MIN_REQUEST_INTERVAL:-5s}
ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false}
ZIM_AUTHORIZATION_URL: ${ZIM_AUTHORIZATION_URL:-https://apigw.zim.com/authorize/v1}
ZIM_TRACING_BASE_URL: ${ZIM_TRACING_BASE_URL:-https://apigw.zim.com/tracing/v2}
ZIM_CLIENT_ID: ${ZIM_CLIENT_ID:-}
ZIM_CLIENT_SECRET: ${ZIM_CLIENT_SECRET:-}
ZIM_TRACKING_SUBSCRIPTION_KEY: ${ZIM_TRACKING_SUBSCRIPTION_KEY:-}
ZIM_TRACKING_CONNECT_TIMEOUT: ${ZIM_TRACKING_CONNECT_TIMEOUT:-10s}
ZIM_TRACKING_REQUEST_TIMEOUT: ${ZIM_TRACKING_REQUEST_TIMEOUT:-30s}
ZIM_TRACKING_MAX_CONCURRENCY: ${ZIM_TRACKING_MAX_CONCURRENCY:-10}
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
EMC_TRACKING_QUERY_URL: ${EMC_TRACKING_QUERY_URL:-https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
EMC_TRACKING_CONNECT_TIMEOUT: ${EMC_TRACKING_CONNECT_TIMEOUT:-10s}
EMC_TRACKING_REQUEST_TIMEOUT: ${EMC_TRACKING_REQUEST_TIMEOUT:-30s}
EMC_TRACKING_MIN_REQUEST_INTERVAL: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:-5s}
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
HEDE_TRACKING_QUERY_URL: ${HEDE_TRACKING_QUERY_URL:-http://elines.hedehk.com/getVBilldynamic}
HEDE_TRACKING_CONNECT_TIMEOUT: ${HEDE_TRACKING_CONNECT_TIMEOUT:-10s}
HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s}
HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s}
event-bridge-service:
<<: *common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"]
ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9300
SERVER_PORT: 9312
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL}
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
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
audit-service:
<<: *common
+15 -4
View File
@@ -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"]
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<profiles>
<profile>
<id>国内-CI-仓库</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内-CI-仓库</activeProfile>
</activeProfiles>
</settings>
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 5 ]; then
echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件>" >&2
exit 2
fi
DEPLOY_ENV="$1"
IMAGE_REPOSITORY="$2"
IMAGE_TAG="$3"
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) ;;
*)
echo "直接镜像传输仅支持 development/test,当前: $DEPLOY_ENV" >&2
exit 2
;;
esac
case "$SERVICE" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $SERVICE" >&2
exit 2
;;
esac
if [ ! -f "$ENV_FILE" ]; then
echo "必需文件不存在: $ENV_FILE" >&2
exit 2
fi
env_file_has_key() {
grep -q "^$1=" "$ENV_FILE"
}
env_file_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
}
case "$SERVICE" in
gateway-overseas|lastleg-service|warehouse-overseas-service)
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas"
;;
event-bridge-service)
BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}"
if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then
BRIDGE_REGION="$(env_file_value REGION_CODE)"
fi
if [ -z "$BRIDGE_REGION" ]; then
echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2
exit 2
fi
case "$BRIDGE_REGION" in
CN) ;;
OVERSEAS)
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas"
;;
*)
echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2
exit 2
;;
esac
;;
esac
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
if [ ! -f "$file" ]; then
echo "必需文件不存在: $file" >&2
exit 2
fi
done
if [ "$SERVICE" = "finance" ]; then
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
case "$SERVICE" in
gateway-overseas|lastleg-service|warehouse-overseas-service)
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署海外服务时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
if [ "$SERVICE" = "firstleg" ]; then
if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
exit 2
fi
fi
if [ "$SERVICE" = "lastleg-service" ]; then
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
if [ "$SERVICE" = "warehouse-overseas-service" ]; then
for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
if [ "$SERVICE" = "event-bridge-service" ]; then
if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL"
else
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL"
fi
for key in $EVENT_BRIDGE_KEYS; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
if [ "$SERVICE" = "file" ]; then
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2
exit 2
fi
if ! env_file_has_key MQ_TOPIC_PREFIX; then
echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2
exit 2
fi
MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX)
if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then
echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2
exit 2
fi
if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then
echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2
exit 2
fi
fi
: "${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
}
wait_for_local_health() {
elapsed=0
while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
health_state="$(local_health_state)"
echo "容器健康状态: $health_state(已等待 ${elapsed}s)"
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
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
echo "新镜像健康检查通过: $IMAGE"
local_compose ps "$SERVICE"
local_registry_logout
trap - EXIT HUP INT TERM
return 0
fi
echo "新镜像健康检查失败: $IMAGE" >&2
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
echo "镜像已在目标服务器本机,直接部署: $IMAGE"
deploy_local
exit 0
fi
SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do
if [ ! -f "$file" ]; then
echo "SSH 文件不存在: $file" >&2
exit 2
fi
done
run_ssh() {
ssh -i "$SSH_KEY" \
-o BatchMode=yes \
-o Compression=no \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" \
-o BatchMode=yes \
-o Compression=no \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE}"
REMOTE_ENV_NAME=".env.$PROJECT_NAME"
REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml"
REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE'
set -eu
env_tmp="$1"
compose_tmp="$2"
env_target="$3"
compose_target="$4"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
mv -f "$env_tmp" "$env_target"
mv -f "$compose_tmp" "$compose_target"
REMOTE_PREPARE
remote_registry_logout() {
run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
}
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"
image_repository="$3"
image_tag="$4"
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"
compose() {
docker compose \
--project-name "$project_name" \
--env-file "$env_file" \
-f "$compose_file" \
"$@"
}
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
echo "新镜像健康检查通过: $new_image"
compose ps "$service"
exit 0
fi
echo "新镜像健康检查失败: $new_image" >&2
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
@@ -0,0 +1,158 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 4 ]; then
echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2
exit 2
fi
IMAGE_REPOSITORY="$1"
IMAGE_TAG="$2"
SERVICE_CSV="$3"
ENV_FILE="$4"
SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}"
SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}"
SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}"
REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}"
COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}"
PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}"
if [ ! -f "$SSH_KEY" ]; then
echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2
exit 2
fi
if [ ! -f "$SSH_KNOWN_HOSTS" ]; then
echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2
exit 2
fi
if [ ! -f "$ENV_FILE" ]; then
echo "开发环境变量文件不存在: $ENV_FILE" >&2
exit 2
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Compose 文件不存在: $COMPOSE_FILE" >&2
exit 2
fi
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ')
for service in $SERVICES; do
case "$service" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $service" >&2
exit 2
;;
esac
done
if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then
echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV"
install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}"
LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP"
install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP"
mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env"
mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml"
for service in $SERVICES; do
docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null
done
cd "$REMOTE_DIR"
export DEPLOY_ENV=development
export IMAGE_REPOSITORY IMAGE_TAG
docker compose \
--project-name "$PROJECT_NAME" \
--env-file .env \
-f docker-compose.yml \
pull $SERVICES
docker compose \
--project-name "$PROJECT_NAME" \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never $SERVICES
docker compose \
--project-name "$PROJECT_NAME" \
--env-file .env \
-f docker-compose.yml \
ps $SERVICES
exit 0
fi
run_ssh() {
ssh -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}"
REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE'
set -eu
remote_dir="$1"
env_tmp="$2"
compose_tmp="$3"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
mv -f "$env_tmp" "$remote_dir/.env"
mv -f "$compose_tmp" "$remote_dir/docker-compose.yml"
REMOTE_PREPARE
for service in $SERVICES; do
image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG"
docker image inspect "$image" >/dev/null
echo "传输开发镜像: $image"
docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load'
done
run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
image_repository="$2"
image_tag="$3"
service_csv="$4"
project_name="$5"
services=$(printf '%s' "$service_csv" | tr ',' ' ')
cd "$remote_dir"
export DEPLOY_ENV=development
export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag"
docker compose \
--project-name "$project_name" \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never $services
docker compose \
--project-name "$project_name" \
--env-file .env \
-f docker-compose.yml \
ps $services
REMOTE_DEPLOY
+114 -14
View File
@@ -19,6 +19,15 @@ fi
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ')
for service in $SERVICES; do
case "$service" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $service" >&2
exit 2
;;
esac
done
env_file_has_key() {
grep -q "^$1=" "$ENV_FILE"
}
@@ -27,6 +36,50 @@ env_file_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
}
DEPLOY_REGION=CN
for service in $SERVICES; do
case "$service" in
gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;;
event-bridge-service)
BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}"
if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then
BRIDGE_REGION="$(env_file_value REGION_CODE)"
fi
if [ -z "$BRIDGE_REGION" ]; then
echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2
exit 2
fi
case "$BRIDGE_REGION" in
CN) ;;
OVERSEAS) DEPLOY_REGION=OVERSEAS ;;
*) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;;
esac
;;
esac
done
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
for service in $SERVICES; do
case "$service" in
gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;;
*) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;;
esac
done
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas"
else
COMPOSE_FILE="deploy/docker-compose.yml"
PROJECT_NAME="firstleg-${DEPLOY_ENV}"
fi
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署海外服务时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
case " $SERVICES " in
*" finance "*)
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
@@ -38,6 +91,53 @@ case " $SERVICES " in
;;
esac
case " $SERVICES " in
*" firstleg "*)
if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
exit 2
fi
;;
esac
case " $SERVICES " in
*" lastleg-service "*)
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
case " $SERVICES " in
*" warehouse-overseas-service "*)
for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
case " $SERVICES " in
*" event-bridge-service "*)
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL"
else
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL"
fi
for key in $EVENT_BRIDGE_KEYS; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
case " $SERVICES " in
*" file "*)
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
@@ -74,25 +174,25 @@ esac
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
PULL_POLICY_ARGS=""
if [ "$DEPLOY_ENV" = "test" ]; then
PULL_POLICY_ARGS="--pull never"
else
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
pull $SERVICES
if [ "$DEPLOY_ENV" = "development" ]; then
DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
fi
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
up -d --remove-orphans $PULL_POLICY_ARGS $SERVICES
-f "$COMPOSE_FILE" \
pull $SERVICES
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
-f "$COMPOSE_FILE" \
up -d --remove-orphans --pull never $SERVICES
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" \
ps $SERVICES
+17 -1
View File
@@ -3,6 +3,11 @@ services:
artifactId: gateway
jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar
port: 9000
gateway-overseas:
artifactId: gateway-overseas
jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar
port: 9300
regions: [OVERSEAS]
auth:
artifactId: auth-service
jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar
@@ -35,10 +40,21 @@ services:
artifactId: integration-service
jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar
port: 9210
lastleg-service:
artifactId: lastleg-service
jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar
port: 9303
regions: [OVERSEAS]
warehouse-overseas-service:
artifactId: warehouse-overseas-service
jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar
port: 9305
regions: [OVERSEAS]
event-bridge-service:
artifactId: event-bridge-service
jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar
port: 9300
port: 9312
regions: [CN, OVERSEAS]
audit-service:
artifactId: audit-service
jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar
+11 -1
View File
@@ -2,7 +2,9 @@
## 唯一数据库初始化与发布入口
- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先执行 `lastleg-service/src/main/resources/db/lastleg.sql` 基线,再执行该入口。当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
@@ -56,6 +58,8 @@
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
## 三期分柜、预装、拣货与装柜当前基线
@@ -215,3 +219,9 @@ SELECT
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
## 四期海外权限目录
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
-- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线;
-- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_event_outbox (
id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本',
event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间',
locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者',
last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key),
KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at)
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
CREATE TABLE IF NOT EXISTS ll_overseas_quote (
id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称',
warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照',
warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照',
provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种',
effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期',
version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code),
KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end)
) ENGINE=InnoDB COMMENT='海外仓报价主表';
CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee (
id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费',
remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no),
KEY idx_ll_quote_fee_quote (quote_id)
) ENGINE=InnoDB COMMENT='海外仓报价仓库费用';
CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price (
id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式',
estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额',
minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注',
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no),
KEY idx_ll_quote_transit_quote (quote_id)
) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细';
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (
id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM',
base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种',
usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), KEY idx_ll_exchange_rate_page (status,rate_month)
) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率';
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history (
id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID',
before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率',
before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态',
operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB COMMENT='月度汇率变更历史';
CREATE TABLE IF NOT EXISTS ll_vehicle (
id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号',
model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升',
mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位',
phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS',
ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED',
status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED',
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id)
) ENGINE=InnoDB COMMENT='平台车辆主数据';
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称',
product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接',
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 '采购入仓仓库名称',
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 '审核时间',
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),
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
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='海外采购订单';
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 '备注',
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='海外采购操作流水';
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 '起运地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址',
container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式',
requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注',
receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
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) COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号',
source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', received_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_us_truck_order_pallet_no(customer_id,order_id,pallet_no), UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no),
KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status)
) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';
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',
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='美国卡车订单状态历史';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
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 '目的详细地址、码头或仓号',
container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' 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', 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_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 '操作备注',
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='本土散板操作流水';
-- 尾程主订单状态历史补齐客户隔离键。代码写入 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;
-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、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',
order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID',
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
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 '乐观锁版本',
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),
KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), KEY idx_ll_inbound_carton_order(order_id,status)
) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'),
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
'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;
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'));
PREPARE phase4_sub_order_status_stmt FROM @ddl; EXECUTE phase4_sub_order_status_stmt; DEALLOCATE PREPARE phase4_sub_order_status_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') LIKE '订单类型:%'),
'SELECT 1',
'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发'''));
PREPARE phase4_order_type_comment_stmt FROM @ddl; EXECUTE phase4_order_type_comment_stmt; DEALLOCATE PREPARE phase4_order_type_comment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') LIKE '当前业务状态:%'),
'SELECT 1',
'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态:PENDING_ASSIGNMENT待分配、PENDING_INBOUND待入库、INBOUND_COMPLETED入库完成、PENDING_DELIVERY待派送、DELIVERING派送中、DELIVERED派送完成'''));
PREPARE phase4_order_status_comment_stmt FROM @ddl; EXECUTE phase4_order_status_comment_stmt; DEALLOCATE PREPARE phase4_order_status_comment_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'),
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''',
'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'),
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''',
'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'),
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''',
'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'),
'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1'));
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.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='idx_ll_inbound_plan_container'),
'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD KEY idx_ll_inbound_plan_container (customer_id,container_no)'));
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'),
'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'));
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_en'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh'));
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'),
'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)'));
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;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'),
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' 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_inbound_stock_receipt' AND column_name='product_name_zh'),
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_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_inbound_stock_receipt' AND column_name='product_name_en'),
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh'));
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_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'),
'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation', '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_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'),
'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, '海外仓报价、月度汇率、车辆主数据、海外业务与入仓单目的仓库存结构已就绪' AS message;
@@ -86,6 +86,7 @@ baiyue-firstleg/
| 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 |
| --- | --- | ---: | --- | --- |
| `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 |
| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 |
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
@@ -94,10 +95,12 @@ baiyue-firstleg/
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 |
| `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` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。
MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。
## 5. 各服务文件夹说明
@@ -155,6 +158,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三
Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。
头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。
## 6. 配置、部署和辅助目录
### `config/`
+21 -19
View File
@@ -2,31 +2,33 @@
## 职责边界
- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。
- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。
- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引
| 功能 | 入口 |
|---|---|
| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` |
| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) |
| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` |
| 校验与 Inbox 编排 | `service/EventInboxService.java` |
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` |
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` |
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| 人工补投 | `service/EventReplayService.java` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 表结构 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` |
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
| 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
## 修改与验证
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。
- 从父目录运行 `mvn -pl event-bridge-service -am test`。
- 定向验证:`mvn -pl event-bridge-service -am test`。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
@@ -1,14 +1,19 @@
package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan("com.baiyue.eventbridge.mapper")
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication {
public static void main(String[] args) {
SpringApplication.run(EventBridgeApplication.class, args);
@@ -0,0 +1,34 @@
package com.baiyue.eventbridge.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
public record EventDispatcherProperties(
URI consumerUrl,
int batchSize,
int maxAttempts,
Duration leaseDuration,
Duration initialBackoff,
Duration maxBackoff,
Duration connectTimeout,
Duration requestTimeout) {
public EventDispatcherProperties {
consumerUrl = consumerUrl == null
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
batchSize = batchSize <= 0 ? 50 : batchSize;
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
}
private static Duration positiveOrDefault(Duration value, Duration fallback) {
return value == null || value.isNegative() || value.isZero() ? fallback : value;
}
}
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.eventbridge.dto.BridgeStatus;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.dto.EventReplayRequest;
import com.baiyue.eventbridge.service.EventInboxService;
import com.baiyue.eventbridge.service.EventReplayService;
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
/**
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
@@ -26,16 +29,14 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/event-bridge")
public class EventBridgeController {
@Resource
private EventInboxService eventInboxService;
@Resource private EventInboxService eventInboxService;
@Resource private EventReplayService eventReplayService;
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
@Resource
private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:CN}")
@Value("${baiyue.region:OVERSEAS}")
private String region;
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
@Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"),
@@ -50,6 +51,18 @@ public class EventBridgeController {
return ApiResponse.success(eventInboxService.receive(envelope));
}
@Operation(summary = "人工补投死信事件")
@PostMapping("/events/{eventId}/replay")
public ApiResponse<Void> replay(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") String operatorId,
@PathVariable String eventId,
@RequestBody EventReplayRequest request) {
internalServiceTokenGuard.verify(token);
eventReplayService.replayDead(eventId, operatorId, request.reason());
return ApiResponse.success(null);
}
@Operation(summary = "查询事件桥能力状态")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
@GetMapping("/status")
@@ -0,0 +1,24 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("bridge_delivery_attempt")
public class BridgeDeliveryAttempt {
@TableId(type = IdType.AUTO)
private Long id;
private String eventId;
private String sourceRegion;
private String targetRegion;
private Integer attemptNo;
private String status;
private String responseCode;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
}
@@ -20,7 +20,7 @@ public class BridgeInbox {
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String tenantId;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
@@ -32,5 +32,11 @@ public class BridgeInbox {
private String payloadHash;
private String payloadJson;
private String status;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private String lastError;
private LocalDateTime receivedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.eventbridge.dto;
public record EventReplayRequest(String reason) {
}
@@ -0,0 +1,9 @@
package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
}
@@ -3,10 +3,61 @@ package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeInbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 入站事件持久化映射。
*/
@Mapper
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select("""
SELECT event_id FROM bridge_inbox
WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit}
""")
List<String> dispatchCandidates(@Param("limit") int limit);
@Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
WHERE event_id=#{eventId}
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""")
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("leaseSeconds") long leaseSeconds);
@Update("""
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
@Update("""
UPDATE bridge_inbox SET retry_count=retry_count+1,
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
lease_owner=NULL, lease_until=NULL, last_error=#{error}
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("maxAttempts") int maxAttempts,
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
int nextAttemptNo(@Param("eventId") String eventId);
@Update("""
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
WHERE event_id=#{eventId} AND status='DEAD'
""")
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
}
@@ -13,8 +13,11 @@ import java.security.MessageDigest;
*/
@Component
public class InternalServiceTokenGuard {
@Value("${baiyue.security.internal-service-token:}")
private String expectedToken;
private final String expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
this.expectedToken = expectedToken;
}
public void verify(String actualToken) {
if (expectedToken == null || expectedToken.isBlank()) {
@@ -1,7 +1,5 @@
package com.baiyue.eventbridge.service;
import jakarta.annotation.Resource;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -13,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
@@ -22,12 +21,11 @@ import java.util.Objects;
*/
@Service
public class EventInboxService {
@Resource
private InboxStore inboxStore;
@Resource
private ObjectMapper objectMapper;
@Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.region:CN}")
private String localRegion;
private String localRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
@@ -35,9 +33,8 @@ public class EventInboxService {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
if (!EventPayloadHash.matches(
EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
@@ -58,25 +55,28 @@ public class EventInboxService {
}
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
}
if (!hasSameEnvelopeIdentity(existing, envelope)) {
throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件");
if (!sameEnvelope(existing, envelope)) {
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
}
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
}
private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) {
return Objects.equals(existing.getTenantId(), envelope.tenantId())
&& Objects.equals(existing.getEventType(), envelope.eventType())
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion());
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
return existing.getPayloadHash() != null
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
&& Objects.equals(existing.getEventType(), envelope.eventType())
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
&& Objects.equals(existing.getCustomerId(), envelope.tenantId())
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
&& 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(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
}
private BridgeInbox toInbox(EventEnvelope envelope) {
@@ -87,7 +87,7 @@ public class EventInboxService {
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setTenantId(envelope.tenantId());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
@@ -0,0 +1,27 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class EventReplayService {
@Resource private BridgeInboxMapper inboxMapper;
public void replayDead(String eventId, String operatorId, String reason) {
if (operatorId == null || operatorId.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供操作人");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供原因");
}
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
}
}
private static String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FirstlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -0,0 +1,52 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFirstlegEventClient implements FirstlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}")
private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -0,0 +1,55 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpLastlegEventClient implements LastlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Value("${baiyue.security.internal-service-token:}")
private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
String body = objectMapper.writeValueAsString(envelope);
HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl())
.timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface LastlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
*/
@Repository
@RequiredArgsConstructor
public class MybatisInboxStore implements InboxStore {
private final BridgeInboxMapper mapper;
@Resource private BridgeInboxMapper mapper;
@Override
public BridgeInbox findByEventId(String eventId) {
@@ -0,0 +1,94 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
@Slf4j
public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient;
@Resource private EventDispatcherProperties properties;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}")
public void dispatch() {
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
dispatchOne(eventId);
}
}
void dispatchOne(String eventId) {
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
return;
}
BridgeInbox event = inboxMapper.selectById(eventId);
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
try {
String responseCode = deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
} catch (Exception exception) {
String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, error);
}
}
private String deliver(BridgeInbox event) throws Exception {
return "CN".equals(event.getTargetRegion())
? firstlegEventClient.deliver(event) : eventClient.deliver(event);
}
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId());
attempt.setSourceRegion(event.getSourceRegion());
attempt.setTargetRegion(event.getTargetRegion());
attempt.setAttemptNo(attemptNo);
attempt.setStatus("PROCESSING");
attempt.setStartedAt(LocalDateTime.now());
attemptMapper.insert(attempt);
return attempt;
}
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
attempt.setStatus(status);
attempt.setResponseCode(responseCode);
attempt.setErrorMessage(error);
attempt.setFinishedAt(LocalDateTime.now());
attemptMapper.updateById(attempt);
}
private long backoffSeconds(int attemptNo) {
long initial = properties.initialBackoff().toSeconds();
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
return Math.min(delay, properties.maxBackoff().toSeconds());
}
private static String abbreviate(String value, int maxLength) {
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
}
}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
@@ -1,15 +1,22 @@
server:
port: ${EVENT_BRIDGE_PORT:9300}
port: ${EVENT_BRIDGE_PORT:9312}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: event-bridge-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
@@ -22,7 +29,18 @@ spring:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:CN}
region: ${REGION_CODE:OVERSEAS}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
@@ -35,14 +53,3 @@ management:
springdoc:
api-docs:
path: /v3/api-docs
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) 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 '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) 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 '聚合版本',
@@ -1,43 +1,29 @@
package com.baiyue.eventbridge.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("内部服务令牌校验")
@SpringJUnitConfig(InternalServiceTokenGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token")
class InternalServiceTokenGuardTest {
@Resource private InternalServiceTokenGuard guard;
@AfterEach
void resetToken() {
ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token");
}
@Test
void validTokenAllowsAccess() {
void 正确令牌允许访问() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
}
@Test
void unconfiguredTokenDisablesReceiveEndpoint() {
ReflectionTestUtils.setField(guard, "expectedToken", "");
void 未配置令牌时默认关闭接收接口() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertThatThrownBy(() -> guard.verify("anything"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("接口已关闭");
}
@Test
void invalidTokenIsRejected() {
ReflectionTestUtils.setField(guard, "expectedToken", "expected");
void 错误令牌被拒绝() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("令牌无效");
@@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
@@ -25,35 +17,17 @@ 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.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("跨区域事件收件箱")
@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class})
@TestPropertySource(properties = "baiyue.region=OVERSEAS")
class EventInboxServiceTest {
@Resource
private ObjectMapper objectMapper;
@MockBean
private InboxStore store;
@Resource
private EventInboxService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@AfterEach
void resetRegion() {
ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS");
}
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final EventInboxService service = service("OVERSEAS");
@Test
void firstEventPersistsAndReturnsReceived() {
void 首次事件落库并返回已接收() {
EventEnvelope envelope = envelope("sha256:placeholder");
envelope = withHash(envelope);
@@ -62,7 +36,22 @@ class EventInboxServiceTest {
}
@Test
void identicalEventAndDigestDoesNotPersistAgain() {
void 拒绝旧数字规范化摘要() {
EventEnvelope source = envelope("sha256:placeholder");
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("payloadHash");
}
@Test
void 相同事件和摘要重复接收不再次写入() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
@@ -71,10 +60,9 @@ class EventInboxServiceTest {
}
@Test
void sameEventWithDifferentDigestRejectsOverwrite() {
void 相同事件但摘要不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId()))
.thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
@@ -82,45 +70,34 @@ class EventInboxServiceTest {
}
@Test
void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() {
EventEnvelope original = withHash(envelope("sha256:placeholder"));
EventEnvelope otherTenant = withTenant(original, "20002");
when(store.findByEventId(otherTenant.eventId()))
.thenReturn(existing(original, original.payloadHash()));
assertThatThrownBy(() -> service.receive(otherTenant))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("信封身份不一致");
}
@Test
void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() {
EventEnvelope original = withHash(envelope("sha256:placeholder"));
EventEnvelope otherEnvelope = withAggregateVersion(original, 2L);
when(store.findByEventId(otherEnvelope.eventId()))
.thenReturn(existing(original, original.payloadHash()));
assertThatThrownBy(() -> service.receive(otherEnvelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("信封身份不一致");
}
@Test
void duplicateInsertAfterRaceIsTreatedAsDuplicate() {
void 相同事件和摘要但聚合标识不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId()))
.thenReturn(null, existing(envelope, envelope.payloadHash()));
EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(),
envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(),
envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash()));
assertThatThrownBy(() -> service.receive(conflicting))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("事件信封");
}
@Test
void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash()));
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void targetRegionDifferentFromCurrentDeploymentIsRejected() {
ReflectionTestUtils.setField(service, "localRegion", "CN");
void 目标区域与当前部署区域不一致时拒绝接收() {
EventInboxService cnService = service("CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThatThrownBy(() -> service.receive(envelope))
assertThatThrownBy(() -> cnService.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("目标区域");
}
@@ -132,6 +109,14 @@ class EventInboxServiceTest {
objectMapper.createObjectNode().put("orderId", "90001"), hash);
}
private EventInboxService service(String region) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "localRegion", region);
return service;
}
private EventEnvelope withHash(EventEnvelope source) {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
@@ -140,32 +125,22 @@ class EventInboxServiceTest {
source.correlationId(), source.causationId(), source.payload(), hash);
}
private EventEnvelope withTenant(EventEnvelope source, String tenantId) {
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(),
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
}
private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) {
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
}
private BridgeInbox existing(EventEnvelope envelope, String hash) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setTenantId(envelope.tenantId());
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
inbox.setHandoverVersion(envelope.handoverVersion());
inbox.setOccurredAt(envelope.occurredAt());
inbox.setTraceId(envelope.traceId());
inbox.setCorrelationId(envelope.correlationId());
inbox.setCausationId(envelope.causationId());
inbox.setPayloadHash(hash);
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
return inbox;
@@ -0,0 +1,36 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class EventReplayServiceTest {
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
private final EventReplayService service = service();
@Test
void requeuesDeadEventWithAuditReason() {
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
service.replayDead("event-1", "operator-7", "incident-42 approved");
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
}
@Test
void rejectsEventThatIsNotDead() {
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
.isInstanceOf(IllegalStateException.class);
}
private EventReplayService service() {
EventReplayService service = new EventReplayService();
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
return service;
}
}
@@ -0,0 +1,111 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
@BeforeEach
void setUp() {
EventDispatcherProperties properties = new EventDispatcherProperties(
URI.create("https://lastleg.internal/events"), 50, 3,
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
Duration.ofSeconds(3), Duration.ofSeconds(10));
dispatcher = new OverseasEventDispatcher();
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
event = new BridgeInbox();
event.setEventId("event-1");
event.setSourceRegion("CN");
event.setTargetRegion("OVERSEAS");
event.setRetryCount(0);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
}
@Test
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
when(eventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void failedDeliveryIsScheduledForRetry() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
}
@Test
void finalFailureIsMovedToDeadState() throws Exception {
event.setRetryCount(2);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
dispatcher.dispatchOne("event-1");
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
}
@Test
void eventClaimedByAnotherInstanceIsSkipped() {
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
}
@Test
void cnTargetIsDeliveredToFirstleg() throws Exception {
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(firstlegEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(firstlegEventClient).deliver(event);
verifyNoInteractions(eventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
return captor.getValue();
}
}
+4 -1
View File
@@ -10,8 +10,10 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -21,6 +23,7 @@
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 全量验收导航
@@ -20,12 +20,14 @@ public class ExportProviderClient {
@Resource private OrderExportProviderClient order;
@Resource private WarehouseExportProviderClient warehouse;
@Resource private FirstlegExportProviderClient firstleg;
@Resource private FinanceExportProviderClient finance;
@Resource
private MeterRegistry meters;
@PostConstruct
void initializeClients() {
this.clients = Map.of("iam-service", iam, "order-service", order,
"warehouse-service", warehouse, "firstleg-service", firstleg);
"warehouse-service", warehouse, "firstleg-service", firstleg,
"finance-service", finance);
}
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -0,0 +1,7 @@
package com.baiyue.file.client;
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name="finance-service", contextId="financeExportProviderClient",
url="${baiyue.clients.finance.url:}", configuration=ExportProviderFeignConfiguration.class)
interface FinanceExportProviderClient extends ExportProviderFeignClient { }
@@ -11,10 +11,10 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service");
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+:[a-z-]+:[a-z-]+");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
private Map<String, Definition> definitions = Map.of();
@PostConstruct
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -43,9 +44,32 @@ public class InternalLoadingEvidenceController {
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (org.springframework.http.InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -36,7 +36,7 @@ public class PlatformLoadingEvidenceController {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
@@ -51,4 +51,13 @@ public class PlatformLoadingEvidenceController {
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -22,7 +22,8 @@ public class InternalBusinessFileService {
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER");
"CONTAINER_PICKUP_ORDER",
"OVERSEAS_UNLOAD_PHOTO");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
+86
View File
@@ -35,6 +35,9 @@ spring:
firstlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
financeExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
servlet:
multipart:
max-file-size: 10MB
@@ -53,6 +56,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
integration:
url: ${INTEGRATION_SERVICE_URL:}
finance:
url: ${FINANCE_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
price-book:
@@ -96,6 +101,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -24,6 +24,7 @@ class ExportProviderClientTest {
@MockBean private OrderExportProviderClient order;
@MockBean private WarehouseExportProviderClient warehouse;
@MockBean private FirstlegExportProviderClient firstleg;
@MockBean private FinanceExportProviderClient finance;
@Resource private ExportProviderClient client;
@Configuration(proxyBeanMethods = false)
@@ -44,6 +45,19 @@ class ExportProviderClientTest {
assertSame(response,actual);
verify(order).batch("token","ORDER_LIST",request);
verifyNoInteractions(iam,warehouse,firstleg);
verifyNoInteractions(iam,warehouse,firstleg,finance);
}
@Test void routesFinanceExportsToFinanceService() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("finance-service");
ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500);
var actual=client.batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",definition,request);
assertSame(response,actual);
verify(finance).batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",request);
verifyNoInteractions(iam,order,warehouse,firstleg);
}
}

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