Compare commits

...
364 Commits
Author SHA1 Message Date
liuyihe 749a0d40a7 Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
2026-08-25 15:06:11 +08:00
ouyangxq f325d57fc1 优化 2026-08-25 14:46:06 +08:00
ouyangxq 5abc7543ae fix(firstleg): align loading completion trigger 2026-08-25 14:41:48 +08:00
ouyangxq 121e8ea619 fix(firstleg): validate settled partial pick totals 2026-08-25 14:33:38 +08:00
ouyangxq 7eca39e4fd fix(firstleg): honor terminal partial pick outcomes 2026-08-25 14:24:59 +08:00
ouyangxq f1af749616 test: target dynamic loading detail precisely 2026-08-25 14:18:13 +08:00
ouyangxq 5fb80d7624 fix(firstleg): reuse running preload lifecycle 2026-08-25 14:07:52 +08:00
ouyangxq 9093550615 test: verify complete PDA batch history 2026-08-25 13:59:58 +08:00
ouyangxq 98766e2b1a fix(firstleg): continue same-plan loading allocations 2026-08-25 13:59:03 +08:00
ouyangxq 72f3f9f971 fix(firstleg): execute carrier schedule due queries 2026-08-25 13:46:33 +08:00
ouyangxq 0302985f21 fix(firstleg): settle zero-package measurements safely 2026-08-25 13:43:22 +08:00
ouyangxq 6d8e4c85c5 test(lastleg): align rest client gateway setup 2026-08-25 13:26:05 +08:00
ouyangxq 2cfaf8e51b fix(lastleg): isolate shipping node version stream 2026-08-25 13:23:05 +08:00
ouyangxq 79eb3efe61 fix(lastleg): align overseas business incremental schema 2026-08-25 13:07:01 +08:00
ouyangxq 995c1833f8 test: align phase3 and overseas business acceptance 2026-08-25 13:06:09 +08:00
ouyangxq d686b05966 fix(firstleg): expose event bridge relay failures 2026-08-25 13:02:26 +08:00
ouyangxq 4cca194eee 优化 2026-08-25 12:30:08 +08:00
ouyangxq 1382902f86 优化 2026-08-25 12:30:07 +08:00
ouyangxq 12dad6673d fix(lastleg): register PDA operation mappers 2026-08-25 12:29:47 +08:00
ouyangxq 4b5e5a1e4e 优化 2026-08-25 12:27:47 +08:00
ouyangxq 0e0b6f8264 test: add architecture and OpenAPI validation tooling 2026-08-25 12:22:14 +08:00
ouyangxq 24df851acf fix(lastleg): register shipping mapper on startup 2026-08-25 12:18:19 +08:00
ouyangxq d9c8cc478e 优化 2026-08-25 11:54:19 +08:00
ouyangxq 322de03312 fix(ci): serialize deployments per service 2026-08-25 11:53:59 +08:00
ouyangxq 4fbf8bfb53 优化 2026-08-25 11:52:25 +08:00
ouyangxq d64706e97b fix: keep development gateways reachable 2026-08-25 11:44:38 +08:00
ouyangxq d066381ae2 fix: expose failed deployment container logs 2026-08-25 11:41:25 +08:00
ouyangxq 6ba4537d3d fix(deploy): bind development ports to loopback 2026-08-25 11:39:12 +08:00
ouyangxq 68bdc3e6bb test: cover overseas pda warehouse operation lifecycles 2026-08-25 11:25:13 +08:00
ouyangxq 042d0b738a feat: complete overseas pda warehouse operations 2026-08-25 11:20:36 +08:00
ouyangxq 45a3b9adfd test: verify pda warehouse queries after receiving 2026-08-25 10:50:58 +08:00
ouyangxq 2a16c91c89 test: sequence pickup readiness before overseas receiving 2026-08-25 10:48:10 +08:00
ouyangxq a6be840588 优化 2026-08-25 10:39:43 +08:00
ouyangxq 52b9fc382f feat: gate overseas receiving and harden phase4 workflows 2026-08-25 10:31:04 +08:00
ouyangxq cb78bc6b41 test: cover partial and concurrent pda completion 2026-08-25 09:58:59 +08:00
ouyangxq b67217332a 优化 2026-08-25 09:52:49 +08:00
ouyangxq b211121365 test: expose acceptance coverage metrics 2026-08-25 09:50:02 +08:00
ouyangxq 46dd0b54eb test: isolate pda receipt idempotency keys 2026-08-25 09:27:45 +08:00
ouyangxq df4b7a1f6e test: carry frozen sku receipts into pda acceptance 2026-08-25 09:23:28 +08:00
ouyangxq 29b2f080d8 test: preserve dropship producer fixture type 2026-08-25 09:16:00 +08:00
ouyangxq 4839243f21 feat: add dropship fulfillment producer chain 2026-08-25 08:40:27 +08:00
ouyangxq c218bfad6b fix: expose warehouse transfer application status 2026-08-25 08:21:47 +08:00
ouyangxq 85a288e4f2 fix: preserve overseas pallet identity 2026-08-25 08:12:58 +08:00
ouyangxq 73d206e3f3 test: complete phase3 phase4 acceptance coverage 2026-08-25 00:24:43 +08:00
ouyangxq 84ad5e779a test: make price-book acceptance portable 2026-08-25 00:08:59 +08:00
ouyangxq 24149c062a fix: migrate event bridge customer columns 2026-08-25 00:02:45 +08:00
ouyangxq e94f8629d2 fix: retain event bridge rejection evidence 2026-08-24 23:56:52 +08:00
ouyangxq 1c40548986 fix: isolate domestic bridge target variable 2026-08-24 23:52:38 +08:00
ouyangxq 55a9d1af09 fix: prioritize domestic bridge endpoint 2026-08-24 23:49:07 +08:00
ouyangxq a22a284e7d fix: route unload events to domestic bridge 2026-08-24 23:43:08 +08:00
ouyangxq 058bf9f4e6 test: complete phase4 cross-region handoff 2026-08-24 23:35:26 +08:00
ouyangxq 615ecb4ba1 test: complete overseas carton and sailing fixtures 2026-08-24 23:28:24 +08:00
ouyangxq 985d952e54 test: provision canonical carrier fixture 2026-08-24 23:19:27 +08:00
ouyangxq bf4fd95841 fix: align phase4 receiving and carrier fixtures 2026-08-24 23:12:04 +08:00
ouyangxq 1e8d85c714 fix: support id-only overseas destinations 2026-08-24 22:51:33 +08:00
ouyangxq 0e73a79479 fix: serialize phase4 inventory and outbound gates 2026-08-24 22:32:37 +08:00
ouyangxq 3fbb7653f7 fix: complete phase4 receiving acceptance chain 2026-08-24 22:14:41 +08:00
ouyangxq 8cc4c6f6da fix: bind overseas receiving to shared warehouse 2026-08-24 21:46:19 +08:00
ouyangxq 3607bebbfd fix: close phase4 overseas acceptance gaps 2026-08-24 21:00:22 +08:00
ouyangxq 005c3f54e2 test: align full acceptance contracts 2026-08-24 20:14:40 +08:00
ouyangxq 5edc2fc1bd fix(lastleg): restore overseas receiving build 2026-08-24 20:14:39 +08:00
ouyangxq c93fdc44d5 1 2026-08-24 19:58:35 +08:00
ouyangxq 9e0f41e78b test: strengthen phase4 overseas business acceptance 2026-08-24 19:45:22 +08:00
ouyangxq a811c2ca06 test: harden carrier node HTTP acceptance 2026-08-24 19:44:28 +08:00
ouyangxq ac806704be chore(test): keep master-data changes out of event gate 2026-08-24 19:42:20 +08:00
ouyangxq bffe7d3b23 test: strengthen overseas warehouse PDA contracts 2026-08-24 19:41:16 +08:00
ouyangxq 1e4b216afa test(events): add cross-region HTTP acceptance gates 2026-08-24 19:40:46 +08:00
ouyangxq 2f986cf5e3 优化 2026-08-24 19:35:01 +08:00
ouyangxq 89bd746dbd 优化 2026-08-24 19:29:39 +08:00
ouyangxq 5c54785d77 优化 2026-08-24 19:28:32 +08:00
ouyangxq aa3e72af28 Merge origin/test into dev 2026-08-24 19:27:54 +08:00
ouyangxq 551560efda 优化 2026-08-24 19:23:20 +08:00
ouyangxq 7e080622f3 fix(ci): isolate event bridge region parameter 2026-08-24 19:15:15 +08:00
ouyangxq 360115de35 修复确认预装后重新分配失败 2026-08-24 18:18:08 +08:00
ouyangxq 111500d010 test(lastleg): align receiving completion fixtures 2026-08-24 18:17:44 +08:00
ouyangxq 4f2abf2894 test(lastleg): type receiving mapper inserts 2026-08-24 18:10:31 +08:00
liuyihe 5c331f173d 提交 2026-08-24 17:58:47 +08:00
ouyangxq 52e4b914b1 fix(lastleg): complete receiving by container 2026-08-24 17:56:43 +08:00
ouyangxq f4a1ec92de 优化 2026-08-24 17:51:11 +08:00
ouyangxq 4893542ec4 1 2026-08-24 17:48:08 +08:00
ouyangxq 633cec7c68 fix(lastleg): disambiguate consumer test fixtures 2026-08-24 17:43:03 +08:00
ouyangxq a320198b47 fix(ci): keep Jenkins boolean continuations parseable 2026-08-24 17:37:36 +08:00
ouyangxq bb74a05562 feat(lastleg): harden container receiving contract 2026-08-24 17:37:15 +08:00
ouyangxq be1eb4f45c fix(ci): carry dual bridge intent through stable parameters 2026-08-24 17:34:11 +08:00
ouyangxq 9d09b77442 fix(ci): bridge BOTH through legacy downstream choices 2026-08-24 17:23:05 +08:00
ouyangxq ce82916036 fix(lastleg): align inbound snapshot with schema 2026-08-24 17:22:18 +08:00
ouyangxq 5a4bcf130a feat(ci): deploy both event bridge regions by default 2026-08-24 17:19:36 +08:00
ouyangxq 3d8a1fac20 优化 2026-08-24 17:15:48 +08:00
liuyihe 01675b8220 提交 2026-08-24 17:12:30 +08:00
ouyangxq 132d96a99b 修复海外报价合作仓候选缓存刷新 2026-08-24 17:10:39 +08:00
ouyangxq 8708904dab 修复装柜清单托盘数快照 2026-08-24 17:03:29 +08:00
ouyangxq 3019f5da77 fix(event-bridge): retain downstream business errors 2026-08-24 16:59:02 +08:00
ouyangxq 8e12dd9a38 fix(test): select container-backed PDA fixtures 2026-08-24 16:40:25 +08:00
ouyangxq 6aca41427c feat(phase4): automate overseas PDA inbound lifecycle 2026-08-24 15:38:28 +08:00
ouyangxq ef9f253f47 feat(lastleg): expose inbound PDA work instructions 2026-08-24 15:25:34 +08:00
ouyangxq 95b72afdcc fix(phase4): enforce customer and pallet versions 2026-08-24 15:14:18 +08:00
ouyangxq e773816c98 fix(db): upgrade overseas pallet FBA field 2026-08-24 15:10:10 +08:00
ouyangxq e11a528dfe test(phase4): provision overseas lifecycle fixtures 2026-08-24 15:07:17 +08:00
liuyihe ce9f08c756 提交 2026-08-24 14:52:50 +08:00
ouyangxq cd97229791 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-24 14:47:54 +08:00
ouyangxq c88d99a593 修复订单列表托数汇总 2026-08-24 14:42:38 +08:00
ouyangxq a8326455fc fix(firstleg): allocate handover carton marks per inbound 2026-08-24 14:40:51 +08:00
ouyangxq 2f32f1d827 test(phase3): finalize loading before split acceptance 2026-08-24 14:34:01 +08:00
ouyangxq e781cbe9cb fix(firstleg): normalize split carton label pools 2026-08-24 14:20:59 +08:00
ouyangxq 8e61cfd202 fix(firstleg): transfer carton labels to remainder inbound 2026-08-24 14:08:17 +08:00
ouyangxq 506cc69808 test(phase3): provision dispatch and handoff evidence 2026-08-24 14:01:02 +08:00
ouyangxq f041cf524b test(phase3): complete remaining hard-gate fixtures 2026-08-24 13:57:02 +08:00
ouyangxq 23335ee8d0 test(phase3): satisfy finalization fixture gates 2026-08-24 13:53:10 +08:00
ouyangxq 98ec62f66e test(phase3): align full acceptance fixtures 2026-08-24 13:48:16 +08:00
ouyangxq b012d64b54 perf(ci): run selected tests once before service builds 2026-08-24 13:43:38 +08:00
ouyangxq 9dd2fcb7e5 test(phase3): validate multi-owner loaded slices 2026-08-24 13:41:59 +08:00
liuyihe 6a86dbcb37 Merge remote-tracking branch 'origin/dev-lyhfive' into dev-lyhfive 2026-08-24 13:41:54 +08:00
ouyangxq 6ddf0d823b fix(firstleg): share container evidence across loading owners 2026-08-24 13:37:47 +08:00
ouyangxqandliuyihe b72dd54b80 fix container customer snapshot schema migration 2026-08-24 13:36:38 +08:00
ouyangxq 92dbeb1676 fix(firstleg): finalize loading before customs acceptance 2026-08-24 12:52:23 +08:00
ouyangxq 4377a7acd9 fix(firstleg): allow confirmed preload reassign during loading 2026-08-24 12:34:34 +08:00
ouyangxq 2389a63858 fix IAM security event correlation id 2026-08-24 12:30:18 +08:00
ouyangxq ae3026dfdb Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-24 12:01:44 +08:00
ouyangxq f1639b9fe3 优化 2026-08-24 12:01:37 +08:00
ouyangxq b3695276b3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
2026-08-24 11:58:22 +08:00
ouyangxq 739c20cb3d fix lastleg order detail customer schema 2026-08-24 11:55:50 +08:00
ouyangxq d59fccc731 优化 2026-08-24 11:52:32 +08:00
ouyangxq 4977b09af9 fix phase3 loading acceptance sequence 2026-08-24 11:49:31 +08:00
ouyangxq 257c019b36 fix phase3 acceptance roots and payable validation 2026-08-24 11:48:16 +08:00
ouyangxq 044678f65c fix carrier history action schema upgrade 2026-08-24 11:36:25 +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
1476 changed files with 96850 additions and 6544 deletions
+1
View File
@@ -1,4 +1,5 @@
target/ target/
node_modules/
.docker-context/ .docker-context/
*.log *.log
.idea/ .idea/
+37 -1
View File
@@ -13,6 +13,34 @@ cache:
paths: paths:
- .m2/repository/ - .m2/repository/
Java拓扑合同:
stage: verify
image: node:22-alpine
cache: []
script:
- node --test scripts/java-topology-contract.test.mjs
OpenAPI有效性:
stage: verify
image: node:22-alpine
cache: []
script:
- npm ci --ignore-scripts --prefix scripts/openapi-validation
- npm test --prefix scripts/openapi-validation
- npm run validate --prefix scripts/openapi-validation
静态分析报告:
stage: verify
allow_failure: true
script:
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
artifacts:
when: always
expire_in: 7 days
paths:
- "**/target/spotbugs*.xml"
- "**/target/site/spotbugs.html"
校验与测试: 校验与测试:
stage: verify stage: verify
script: script:
@@ -25,10 +53,14 @@ cache:
- "**/target/surefire-reports/TEST-*.xml" - "**/target/surefire-reports/TEST-*.xml"
paths: paths:
- "**/target/surefire-reports/" - "**/target/surefire-reports/"
- "**/target/site/jacoco/"
- "**/target/jacoco.exec"
打包服务: 打包服务:
stage: package stage: package
needs: needs:
- Java拓扑合同
- OpenAPI有效性
- 校验与测试 - 校验与测试
script: script:
- mvn $MAVEN_CLI_OPTS -DskipTests package - mvn $MAVEN_CLI_OPTS -DskipTests package
@@ -36,14 +68,18 @@ cache:
expire_in: 7 days expire_in: 7 days
paths: paths:
- gateway/target/*.jar - gateway/target/*.jar
- gateway-overseas/target/*.jar
- auth/target/*.jar - auth/target/*.jar
- iam/target/*.jar - iam/target/*.jar
- order/target/*.jar - order/target/*.jar
- firstleg/target/*.jar - firstleg/target/*.jar
- finance/target/*.jar
- warehouse/target/*.jar - warehouse/target/*.jar
- warehouse-overseas-service/target/*.jar
- overseas-security-service/target/*.jar
- lastleg-service/target/*.jar
- file/target/*.jar - file/target/*.jar
- print/target/*.jar - print/target/*.jar
- audit-service/target/*.jar - audit-service/target/*.jar
- integration-service/target/*.jar - integration-service/target/*.jar
- event-bridge-service/target/*.jar - event-bridge-service/target/*.jar
Binary file not shown.
+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();
}
}
}
}
}
+42 -7
View File
@@ -1,16 +1,17 @@
# 国内头程后端项目指引 # 头程与海外仓后端项目指引
## 适用范围与定位 ## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 不可突破的产品与数据边界 ## 不可突破的产品与数据边界
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。 - 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位) ## 服务路由(按关键词定位)
@@ -23,8 +24,10 @@
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | | 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | | 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
@@ -32,26 +35,57 @@
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` | | 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-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`、`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。 - 跨服务修改先检查调用方 `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`。 - 平台后台存在正式列表页时,`*: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` 必须停用/移除,不得重新提升或兼容。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。 - 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`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 验收脚本路由
- 全量验收唯一指向测试环境 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`。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
@@ -62,6 +96,7 @@
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md` - 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 - 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+99 -34
View File
@@ -1,10 +1,11 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service'
] ]
def serviceCheckboxes = [ def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY', 'gateway': 'SERVICE_GATEWAY',
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
'auth': 'SERVICE_AUTH', 'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM', 'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG', 'firstleg': 'SERVICE_FIRSTLEG',
@@ -14,6 +15,9 @@ def serviceCheckboxes = [
'file': 'SERVICE_FILE', 'file': 'SERVICE_FILE',
'print': 'SERVICE_PRINT', 'print': 'SERVICE_PRINT',
'integration-service': 'SERVICE_INTEGRATION', 'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT' 'audit-service': 'SERVICE_AUDIT'
] ]
@@ -28,9 +32,21 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:] 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 { pipeline {
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
agent any agent none
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
@@ -41,6 +57,7 @@ pipeline {
parameters { parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
@@ -50,34 +67,41 @@ pipeline {
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)') booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') 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: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth') string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
} }
stages { stages {
stage('确定构建范围') { stage('确定构建范围') {
agent { label 'java-builder' }
steps { steps {
script { script {
String requestedRepository // 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
if (params.ENVIRONMENT == 'test') { // 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
requestedRepository = 'baiyue-firstleg-test' String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry' ? 'development'
} else { : params.ENVIRONMENT
requestedRepository = params.IMAGE_REPOSITORY?.trim() env.RESOLVED_ENVIRONMENT = resolvedEnvironment
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
error('预发布/生产环境必须填写真实镜像仓库地址')
} String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') 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 env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
@@ -86,6 +110,9 @@ pipeline {
} }
env.RESOLVED_JOB_PREFIX = jobPrefix env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false' env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
List selected = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
@@ -124,6 +151,19 @@ pipeline {
} }
selected = selected.unique() selected = selected.unique()
if (params.DEPLOY && selected.contains('event-bridge-service')) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
if (bridgeRegion == 'SELECT') {
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
}
}
env.SELECTED_SERVICES = selected.join(',') env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) { if (!selected) {
@@ -147,7 +187,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') { stage('生产环境部署确认(仅 production 执行)') {
when { when {
allOf { 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 { steps {
@@ -155,14 +195,30 @@ pipeline {
} }
} }
stage('测试数据库初始化(可选)') { stage('统一 Maven 测试门禁') {
when { when {
allOf { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } }
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
steps {
script {
String selectedModules = env.SELECTED_SERVICES.split(',')
.collect { it.trim() }
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
} }
} }
steps { post {
sh 'sh deploy/scripts/initialize-test-databases.sh' always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
} }
} }
@@ -175,6 +231,14 @@ pipeline {
String service = selectedService String service = selectedService
branches[service] = { branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
boolean deployBothBridges = service == 'event-bridge-service' &&
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
String downstreamImageTag = deployBothBridges
? "${env.IMAGE_TAG}-both"
: env.IMAGE_TAG
String downstreamBridgeRegion = deployBothBridges
? 'CN'
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
echo "触发单服务 Job: ${jobName}" echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build( def downstreamBuild = build(
job: jobName, job: jobName,
@@ -182,12 +246,16 @@ pipeline {
propagate: false, propagate: false,
parameters: [ parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'IMAGE_TAG', value: downstreamImageTag),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY), booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE) string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
] ]
) )
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
@@ -216,7 +284,4 @@ pipeline {
} }
} }
post {
always { cleanWs(deleteDirs: true, notFailBuild: true) }
}
} }
+214 -103
View File
@@ -1,10 +1,11 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service'
] ]
def serviceJars = [ def serviceJars = [
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
@@ -14,17 +15,16 @@ def serviceJars = [
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar',
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
] ]
pipeline { pipeline {
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。 // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent any agent none
tools {
maven 'maven-3.8.8'
}
options { options {
skipDefaultCheckout(true) skipDefaultCheckout(true)
@@ -36,117 +36,202 @@ pipeline {
parameters { parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') 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 { environment {
REGISTRY_CREDENTIALS = 'gitea-test-token' REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
} }
stages { stages {
stage('校验参数并检出指定提交') { stage('构建并发布单服务') {
steps { agent { label 'java-builder' }
checkout scm tools { maven 'maven-3.8.8' }
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
script { script {
String service = serviceModules.find { candidate -> String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}") env.JOB_BASE_NAME?.endsWith("-${candidate}")
} }
String sourceCommit = params.SOURCE_COMMIT?.trim() String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim() String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim() String imageRepository = params.IMAGE_REPOSITORY?.trim()
if (!sourceCommit) { if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
} }
if (!imageTag) { if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
} }
if (!serviceModules.contains(service)) { if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
} }
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { boolean deployBothBridges = service == 'event-bridge-service' &&
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') imageTag?.endsWith('-both')
} if (deployBothBridges) {
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) { imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
error('IMAGE_TAG 不是合法的 Docker 镜像标签') }
} if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
if (!imageRepository || imageRepository.contains('://')) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://') }
} if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) { error('IMAGE_TAG 不是合法的 Docker 镜像标签')
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') }
} if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
env.RESOLVED_SERVICE = service env.RESOLVED_SERVICE = service
env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
if (service == 'event-bridge-service' && params.DEPLOY) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
!['development', 'test'].contains(params.ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
}
}
sh '''
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 ''' stage('Maven 测试与打包') {
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then steps {
git fetch --no-tags origin script {
fi boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" }
''' boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
script { stage('构建并发布单服务镜像') {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}" steps {
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}" 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 { steps {
script { script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) { String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
error('生产环境禁止跳过测试') String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
} String credentialId = "firstleg-${params.ENVIRONMENT}-env"
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
String testOption = skipTestExecution ? '-DskipTests' : '' // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
if (params.ENVIRONMENT == 'test') { // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' lock(resource: serviceDeployResource, inversePrecedence: true) {
} lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" node(targetLabel) {
} deleteDir()
} try {
post { unstash 'deployment-files'
always { echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
} (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
} ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
} ? ['CN', 'OVERSEAS']
: [params.EVENT_BRIDGE_REGION]
stage('构建并发布单服务镜像') { withCredentials([
steps { file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
script { usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}" usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}" ]) {
String jar = serviceJars[env.RESOLVED_SERVICE] bridgeRegions.each { bridgeRegion ->
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'" if (env.RESOLVED_SERVICE == 'event-bridge-service') {
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" echo "部署事件桥实例: ${bridgeRegion}"
}
if (params.ENVIRONMENT == 'test') { withEnv([
echo "测试镜像已保存在当前 Docker 节点:${image}" 'TARGET_IS_LOCAL=true',
} else { "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] ]) {
withCredentials([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\""
try { }
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' }
sh "docker push ${image}" }
} finally { } finally {
sh "docker logout ${registryHost} || true" cleanWs(deleteDirs: true, notFailBuild: true)
}
} }
} }
} }
@@ -155,15 +240,44 @@ pipeline {
} }
stage('部署单个服务') { 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 { steps {
deleteDir()
unstash 'deployment-files'
script { script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env" String credentialId = "firstleg-${params.ENVIRONMENT}-env"
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" 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 +285,5 @@ pipeline {
success { success {
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}" echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
} }
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
} }
} }
+9 -5
View File
@@ -1,6 +1,6 @@
# 柏悦头程平台 # 柏悦头程平台
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
@@ -12,20 +12,24 @@
- `firstleg`:头程揽收、入仓、装载、发运和交接。 - `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。 - `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 - `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 - `platform-bom`:统一管理仓库内公共依赖版本。
## 本地构建 ## 本地构建
```powershell ```powershell
mvn -DskipTests validate mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test mvn test
``` ```
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
@@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界 ## 仓库边界
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
@@ -1,8 +1,10 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan("com.baiyue.audit.mapper")
@EnableScheduling @EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
public class BaiyueAuditApplication { public class BaiyueAuditApplication {
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
String username, String username,
String appCode, String appCode,
String clientType, String clientType,
String clientIp,
String requestId, String requestId,
String traceId, String traceId,
String serviceName, String serviceName,
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
return new AuditLogResponses.ListItem( return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(), entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(), 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.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt()); 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.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
verify(service).detail(8L); 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) { private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list")); return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
} }
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData"); .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 @Test
void detailReturnsSanitizedSnapshots() { void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L)); when(repository.findById(8L)).thenReturn(log(8L));
+6 -2
View File
@@ -4,6 +4,7 @@
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口 ## 模块入口
@@ -12,7 +13,10 @@
|---|---| |---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.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 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` | | 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -22,7 +26,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 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -21,4 +21,12 @@ public class AuthorizationSnapshot {
private Set<String> roles; private Set<String> roles;
private Set<String> permissions; private Set<String> permissions;
private Long permissionVersion; private Long permissionVersion;
private Long overseasWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
} }
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService; import com.baiyue.auth.service.AuthService;
import com.baiyue.auth.service.OverseasSessionValidationService;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
/** 仅供可信内部服务调用的账号管理接口。 */ /** 仅供可信内部服务调用的账号管理接口。 */
@RestController @RestController
@RequestMapping("/internal/auth/accounts") @RequestMapping("/internal/auth/accounts")
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
public class InternalAccountController { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource @Resource
private AuthService authService; private AuthService authService;
@Resource
private OverseasSessionValidationService overseasSessionValidationService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -64,6 +71,15 @@ public class InternalAccountController {
@Valid @RequestBody RevokeAccountSessionsRequest request) { @Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
} }
@PostMapping("/overseas-session/validate")
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request));
}
@PutMapping("/{id}/profile") @PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
@@ -0,0 +1,29 @@
package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
public final class OverseasSessionValidationDtos {
private OverseasSessionValidationDtos() { }
@Schema(name = "OverseasSessionValidationRequest",
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
public record Request(
@NotNull Long userId,
@NotNull Integer tokenVersion,
Long overseasWarehouseId) { }
@Schema(name = "OverseasSessionValidationResponse")
public record Response(boolean valid, FailureReason failureReason) {
public static Response accepted() { return new Response(true, null); }
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
}
public enum FailureReason {
ACCOUNT_INVALID,
TOKEN_VERSION_MISMATCH,
WAREHOUSE_UNBOUND,
WAREHOUSE_MISMATCH
}
}
@@ -11,6 +11,10 @@ public record TokenResponse(
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn
) { ) {
} }
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
@@ -49,6 +50,8 @@ public class AuthService {
private AuthorizationLoader authorizationLoader; private AuthorizationLoader authorizationLoader;
@Resource @Resource
private TokenSessionStore tokenSessionStore; private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@@ -108,7 +111,7 @@ public class AuthService {
Instant issuedAt = Instant.now(); Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString(); String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience())) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString()) .subject(account.getId().toString())
@@ -124,8 +127,11 @@ public class AuthService {
.claim("userId", account.getId()) .claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId()) .claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()) .claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion()) .claim("permissionVersion", account.getTokenVersion());
.build(); if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue(); signingHeader(), claims)).getTokenValue();
@@ -134,8 +140,13 @@ public class AuthService {
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl()); properties.accessTokenTtl(), properties.refreshTokenTtl());
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds()); rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn());
} }
@Transactional @Transactional
@@ -0,0 +1,38 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Objects;
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
@Service
public class OverseasSessionValidationService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
public Response validate(Request request) {
AuthAccount account = accounts.findById(request.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
return Response.rejected(FailureReason.ACCOUNT_INVALID);
}
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
if (currentVersion != request.tokenVersion()) {
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
}
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
}
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
}
return Response.accepted();
}
}
@@ -19,12 +19,21 @@ import org.springframework.stereotype.Service;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ /** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service @Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") @ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService { 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 AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader; @Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties; @Resource private OverseasJwtProperties properties;
@@ -36,21 +45,38 @@ public class OverseasTokenService {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
} }
AuthorizationSnapshot authorization = authorizationLoader.load(account); AuthorizationSnapshot authorization = authorizationLoader.load(account);
return issue(account, authorization);
}
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now(); Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience())) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString()) .subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt) .issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles()) .claim("membership_id", authorization.getMembershipId())
.claim("authorities", authorization.getPermissions()) .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) .claim("roles", authorization.getRoles())
.build(); .claim("authorities", overseasAuthorities(authorization.getPermissions()))
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from( String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); 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) { } public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
} }
@@ -0,0 +1,28 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasSessionValidationControllerContractTest {
@Test
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/overseas-session/validate");
assertThat(method.getAnnotation(Operation.class).description())
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
.doesNotContain("token", "accessToken", "authorization", "password");
}
}
@@ -23,7 +23,10 @@ import java.time.Duration;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -34,6 +37,7 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder; @MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader; @MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore; @MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service; @Resource private AuthService service;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@@ -59,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM"); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1); account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list")); "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account); when(repository.findByUsername("operator")).thenReturn(account);
@@ -67,8 +71,10 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token"); when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
service.login(new LoginRequest("operator", "secret")); var response = service.login(new LoginRequest("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture()); verify(jwtEncoder).encode(jwt.capture());
@@ -79,15 +85,76 @@ class AuthServiceSmallJwtTest {
.containsEntry("appCode", "PLATFORM") .containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1) .containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L) .containsEntry("membership_id", 84L)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope"); .doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(10001L);
account.setDefaultMembershipId(94L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(3);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
var response = service.refresh("refresh-old");
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
assertThat(response.tokenType()).isEqualTo("Bearer");
assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
verify(tokenSessionStore).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("刷新令牌无效或已过期");
verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
} }
@Test @Test
@@ -102,7 +169,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("MERCHANT"); account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2); account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")); "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket( TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support"); "ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
@@ -0,0 +1,77 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class OverseasSessionValidationServiceTest {
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
private AuthAccount account;
@BeforeEach
void setUp() throws Exception {
inject("accounts", accounts);
inject("authorizationLoader", authorizationLoader);
account = new AuthAccount();
account.setId(42L);
account.setAccountStatus("ACTIVE");
account.setTokenVersion(8);
when(accounts.findById(42L)).thenReturn(account);
}
@Test
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
assertThat(service.validate(new Request(42L, 7, 900L)))
.extracting("valid", "failureReason")
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
}
@Test
void missingOrChangedWarehouseBindingFailsClosed() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
}
@Test
void disabledAccountFailsClosedBeforeIamLookup() {
account.setAccountStatus("DISABLED");
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
.isEqualTo(FailureReason.ACCOUNT_INVALID);
}
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
}
private void inject(String fieldName, Object value) throws Exception {
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(service, value);
}
}
@@ -24,6 +24,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) @SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@@ -45,10 +46,13 @@ class OverseasTokenServiceTest {
@Test @Test
void signsShortRs256TokenWithCrossRegionClaims() { void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount(); 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"); account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB", String authority = "platform:overseas-master-data:quote:list";
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L); String lastlegAuthority = "lastleg:pda:operate";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account); when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -64,11 +68,65 @@ class OverseasTokenServiceTest {
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims()) assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L) .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L) .containsEntry("membership_id", 84L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN")) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("authorities", Set.of("merchant:order:list")) .containsEntry("permission_version", 7L)
.doesNotContainKeys("user_id", "tenant_id", "app_code"); .containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900); assertThat(response.expiresIn()).isEqualTo(900);
} }
@Test
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
AuthAccount account = new AuthAccount();
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second);
when(first.getTokenValue()).thenReturn("overseas-old-binding");
when(second.getTokenValue()).thenReturn("overseas-new-binding");
var oldToken = service.issue(account, oldBinding);
var newToken = service.issue(account, newBinding);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder, times(2)).encode(captor.capture());
var oldClaims = captor.getAllValues().get(0).getClaims();
var newClaims = captor.getAllValues().get(1).getClaims();
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
.containsEntry("permission_version", 8L);
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
.containsEntry("permission_version", 9L);
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
}
@Test
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
service.issue(account, unbound);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
assertThat(captor.getValue().getClaims().getClaims())
.doesNotContainKey("overseas_warehouse_id");
}
} }
@@ -48,7 +48,26 @@ class BusinessAuditAspectTest {
assertThat(event.operatorTenantId()).isEqualTo(10001L); 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 { 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); AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect(); BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink); ReflectionTestUtils.setField(aspect, "sink", sink);
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
when(joinPoint.getSignature()).thenReturn(signature); when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target); when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId}); 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); ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture()); verify(sink).publish(event.capture());
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
assertThat(parsed.path("_truncated").asBoolean()).isTrue(); assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40); 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 调用方都按字符串严格判断。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。 - 新能力只有被多个模块稳定复用时才放入本模块。
+11 -4
View File
@@ -14,12 +14,19 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证 ## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。 - 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
- 从父目录运行 `mvn -pl common-event-contract -am test`。 - 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -10,6 +10,9 @@ public final class EventTypes {
public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -0,0 +1,47 @@
package com.baiyue.common.event;
import java.time.Instant;
import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String customerId,
String oceanBookingId,
String containerBookingId,
String containerNo,
String standardNode,
int nodeSequence,
Instant observedAt,
Instant actualAt,
String locationCode,
String locationName,
String providerCode,
String providerEventId,
String providerNodeCode,
String sourceReference,
String rawPayloadHash) {
private static final Map<String, Integer> STANDARD_NODES = Map.of(
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
"EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|| oceanBookingId == null || oceanBookingId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
}
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
}
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
}
}
}
@@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
String destinationType) {
public Order { public Order {
items = items == null ? List.of() : items; items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId, String inboundCode, String destinationWarehouseId,
@@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1(
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
} }
} }
@@ -0,0 +1,96 @@
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,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark,
String palletNo,
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
null, List.of());
}
}
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 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,16 @@
package com.baiyue.common.event;
import java.time.Instant;
/**
* 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。
* unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。
*/
public record OverseasContainerUnloadedV1(
String sourceContainerBookingId,
String containerNo,
Instant unloadingCompletedAt,
Instant unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt
) {
}
@@ -0,0 +1,49 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegContainerShippingNodeRecordedV1Test {
@Test
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
var payload = payload();
var mapper = new ObjectMapper().findAndRegisterModules();
var restored = mapper.readValue(mapper.writeValueAsString(payload),
FirstLegContainerShippingNodeRecordedV1.class);
assertThat(restored).isEqualTo(payload);
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80);
}
@Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
.isInstanceOf(IllegalArgumentException.class);
}
private static FirstLegContainerShippingNodeRecordedV1 payload() {
return new FirstLegContainerShippingNodeRecordedV1(
"2088000000000000001", "2088000000000000002", "2088000000000000004",
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
"ZIM:evt-1", "a".repeat(64));
}
}
@@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test {
assertThat(restored).isEqualTo(payload); assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
} }
@Test
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
}
} }
@@ -0,0 +1,69 @@
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 carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
}
@Test
void mapsEachOrdinaryCartonNumberToItsSku() {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
}
}
+1 -1
View File
@@ -18,6 +18,6 @@
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。 - 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 从父目录运行 `mvn -pl common-openapi -am test`。 - 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 - 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
.addOperationCustomizer(correlationHeaderCustomizer).build(); .addOperationCustomizer(correlationHeaderCustomizer).build();
} }
@Bean
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
return GroupedOpenApi.builder().group("pda")
.displayName("PDA APIs / PDA 作业接口")
.pathsToMatch("/api/pda/**")
.addOperationCustomizer(correlationHeaderCustomizer).build();
}
private Parameter header(String name, String description, boolean required) { private Parameter header(String name, String description, boolean required) {
return new HeaderParameter().name(name).description(description).required(required) return new HeaderParameter().name(name).description(description).required(required)
.schema(new io.swagger.v3.oas.models.media.StringSchema()); .schema(new io.swagger.v3.oas.models.media.StringSchema());
@@ -0,0 +1,17 @@
package com.baiyue.common.openapi;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PdaOpenApiGroupContractTest {
@Test
void pdaGroupOnlyMatchesDeviceApis() {
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
assertThat(group.getGroup()).isEqualTo("pda");
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
}
}
+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` | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.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` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
## 修改与验证 ## 修改与验证
- 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 和区分授权失败。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。 - 从父目录运行 `mvn -pl common-security -am test`。
@@ -9,11 +9,19 @@ import java.util.Set;
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, String username, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId, Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) implements Principal { Long actorMembershipId, boolean passwordChangeRequired,
Long overseasWarehouseId) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) { String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, false, null, null, false); permissions, false, null, null, false, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
} }
public AuthenticatedUser { public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions); permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
@@ -43,7 +43,8 @@ public class JwtClaimsMapper {
Boolean.TRUE.equals(jwt.getClaim("impersonated")), Boolean.TRUE.equals(jwt.getClaim("impersonated")),
longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_user_id"),
longClaim(jwt, "actor_membership_id"), longClaim(jwt, "actor_membership_id"),
Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
longClaim(jwt, "overseas_warehouse_id"));
List<SimpleGrantedAuthority> authorities = permissions.stream() List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList(); .map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
.claim("actor_user_id", 42L) .claim("actor_user_id", 42L)
.claim("actor_membership_id", 43L) .claim("actor_membership_id", 43L)
.claim("password_change_required", true) .claim("password_change_required", true)
.claim("overseas_warehouse_id", 900L)
.build(); .build();
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorUserId()).isEqualTo(42L);
assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.actorMembershipId()).isEqualTo(43L);
assertThat(user.passwordChangeRequired()).isTrue(); assertThat(user.passwordChangeRequired()).isTrue();
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
} }
} }
+5 -1
View File
@@ -11,6 +11,8 @@
|---|---| |---|---|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
@@ -22,6 +24,8 @@
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。 - 普通 `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`。 - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
- 从父目录运行 `mvn -pl common-web -am test`。 - 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+5
View File
@@ -39,6 +39,11 @@
<groupId>io.micrometer</groupId> <groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId> <artifactId>micrometer-registry-prometheus</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
public final class CorrelationContext { public final class CorrelationContext {
public static final String REQUEST_ID = "requestId"; public static final String REQUEST_ID = "requestId";
public static final String TRACE_ID = "traceId"; public static final String TRACE_ID = "traceId";
public static final String SPAN_ID = "spanId";
public static final String TRACE_PARENT = "traceparent";
public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String REQUEST_ID_HEADER = "X-Request-Id";
public static final String TRACE_ID_HEADER = "X-Trace-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
private CorrelationContext() {} private CorrelationContext() {}
@@ -22,6 +25,14 @@ public final class CorrelationContext {
return value(request, TRACE_ID); return value(request, TRACE_ID);
} }
public static String spanId(HttpServletRequest request) {
return value(request, SPAN_ID);
}
public static String traceParent(HttpServletRequest request) {
return value(request, TRACE_PARENT);
}
private static String value(HttpServletRequest request, String key) { private static String value(HttpServletRequest request, String key) {
Object attribute = request.getAttribute(key); Object attribute = request.getAttribute(key);
return attribute == null ? MDC.get(key) : attribute.toString(); return attribute == null ? MDC.get(key) : attribute.toString();
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Enumeration;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); String requestId = requestId(request);
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); TraceParent traceParent = traceParent(request);
String traceId = traceParent.traceId();
request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
request.setAttribute(CorrelationContext.TRACE_ID, traceId); request.setAttribute(CorrelationContext.TRACE_ID, traceId);
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
} }
private String acceptedOrGenerated(String candidate, boolean compact) { private String requestId(HttpServletRequest request) {
if (candidate != null && SAFE_ID.matcher(candidate).matches()) { HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
return candidate; if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
return header.value();
}
return UUID.randomUUID().toString();
}
private TraceParent traceParent(HttpServletRequest request) {
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
TraceParent.HEADER_LENGTH);
if (standard.present()) {
if (standard.valid()) {
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
.orElseGet(() -> TraceParent.newRoot(null));
}
return TraceParent.newRoot(null);
}
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
}
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
Enumeration<String> values = request.getHeaders(name);
if (values == null || !values.hasMoreElements()) {
return HeaderValue.missing();
}
String value = values.nextElement();
boolean duplicate = values.hasMoreElements();
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
}
private record HeaderValue(boolean present, boolean valid, String value) {
private static HeaderValue missing() {
return new HeaderValue(false, false, null);
} }
String id = UUID.randomUUID().toString();
return compact ? id.replace("-", "") : id;
} }
} }
@@ -0,0 +1,18 @@
package com.baiyue.common.web;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
/** Enables synchronous Feign trace propagation without making Feign a required dependency. */
@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration")
@ConditionalOnClass(name = "feign.RequestInterceptor")
public class FeignTracePropagationAutoConfiguration {
@Bean
@ConditionalOnMissingBean(FeignTraceRequestInterceptor.class)
FeignTraceRequestInterceptor feignTraceRequestInterceptor() {
return new FeignTraceRequestInterceptor();
}
}
@@ -0,0 +1,41 @@
package com.baiyue.common.web;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import java.util.regex.Pattern;
/** Propagates the trusted synchronous request trace context to every Feign invocation. */
public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered {
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
@Override
public void apply(RequestTemplate template) {
removeManagedHeaders(template);
String requestId = MDC.get(CorrelationContext.REQUEST_ID);
if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) {
template.header(CorrelationContext.REQUEST_ID_HEADER, requestId);
}
String traceId = MDC.get(CorrelationContext.TRACE_ID);
if (TraceParent.validLegacyTraceId(traceId)) {
TraceParent outbound = TraceParent.outboundChild(traceId);
template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value());
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
private void removeManagedHeaders(RequestTemplate template) {
template.removeHeader(CorrelationContext.REQUEST_ID_HEADER);
template.removeHeader(CorrelationContext.TRACE_ID_HEADER);
template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER);
}
}
@@ -21,6 +21,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -79,6 +81,21 @@ public class GlobalExceptionHandler {
return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request); return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request);
} }
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(
ResponseStatusException exception, HttpServletRequest request) {
ResultCode code = resultCode(exception.getStatusCode().value());
String message = exception.getReason() == null || exception.getReason().isBlank()
? code.message() : exception.getReason();
return failure(code, message, request);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException exception, HttpServletRequest request) {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}", log.error("Unhandled request failure requestId={} traceId={}",
@@ -123,4 +140,18 @@ public class GlobalExceptionHandler {
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment(); meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
} }
} }
private ResultCode resultCode(int status) {
return switch (status) {
case 400 -> ResultCode.BAD_REQUEST;
case 401 -> ResultCode.UNAUTHORIZED;
case 403 -> ResultCode.FORBIDDEN;
case 404 -> ResultCode.NOT_FOUND;
case 409 -> ResultCode.CONFLICT;
case 422 -> ResultCode.VALIDATION_FAILED;
case 429 -> ResultCode.TOO_MANY_REQUESTS;
case 503 -> ResultCode.SERVICE_UNAVAILABLE;
default -> ResultCode.INTERNAL_ERROR;
};
}
} }
@@ -0,0 +1,92 @@
package com.baiyue.common.web;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Strict W3C {@code traceparent} version 00 value.
*
* <p>This type deliberately accepts only the currently frozen version-00 shape. Unsupported,
* ambiguous or malformed input must start a new trace instead of being partially trusted.</p>
*/
public record TraceParent(String traceId, String parentId, String traceFlags) {
public static final String HEADER = "traceparent";
public static final int HEADER_LENGTH = 55;
private static final Pattern VERSION_00 = Pattern.compile(
"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})");
private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}");
private static final String ZERO_TRACE_ID = "0".repeat(32);
private static final String ZERO_PARENT_ID = "0".repeat(16);
private static final SecureRandom RANDOM = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
public TraceParent {
if (!validTraceId(traceId) || !validParentId(parentId)
|| traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) {
throw new IllegalArgumentException("Invalid W3C traceparent components");
}
}
/** Parses one exact, lowercase W3C version-00 header value. */
public static Optional<TraceParent> parse(String value) {
if (value == null || value.length() != HEADER_LENGTH) {
return Optional.empty();
}
Matcher matcher = VERSION_00.matcher(value);
if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) {
return Optional.empty();
}
return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3)));
}
/** Creates the local inbound context for a valid remote parent. */
public static TraceParent childOf(TraceParent remoteParent) {
return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags());
}
/** Creates a new root context, optionally reusing a valid legacy trace ID. */
public static TraceParent newRoot(String legacyTraceId) {
String traceId = validLegacyTraceId(legacyTraceId)
? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16);
return new TraceParent(traceId, randomNonZero(8), "00");
}
/** Creates a sampled outbound child for a trusted current trace ID. */
public static TraceParent outboundChild(String currentTraceId) {
if (!validLegacyTraceId(currentTraceId)) {
throw new IllegalArgumentException("Current trace ID is invalid");
}
return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01");
}
public static boolean validLegacyTraceId(String value) {
return value != null && LEGACY_TRACE_ID.matcher(value).matches()
&& !ZERO_TRACE_ID.equalsIgnoreCase(value);
}
public String value() {
return "00-" + traceId + '-' + parentId + '-' + traceFlags;
}
private static boolean validTraceId(String value) {
return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value);
}
private static boolean validParentId(String value) {
return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value);
}
private static String randomNonZero(int byteCount) {
byte[] bytes = new byte[byteCount];
String value;
do {
RANDOM.nextBytes(bytes);
value = HEX.formatHex(bytes);
} while (value.chars().allMatch(character -> character == '0'));
return value;
}
}
@@ -1,2 +1,3 @@
com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.LongIdJacksonConfiguration
com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.CommonWebAutoConfiguration
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
@@ -1,6 +1,8 @@
package com.baiyue.common.web; package com.baiyue.common.web;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest {
contextRunner.run(context -> { contextRunner.run(context -> {
assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(GlobalExceptionHandler.class);
assertThat(context).hasSingleBean(CorrelationIdFilter.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class);
assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value())
.isEqualTo(Ordered.HIGHEST_PRECEDENCE);
}); });
} }
@@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("请求链路标识过滤功能") @DisplayName("请求链路标识过滤功能")
@@ -13,27 +15,87 @@ class CorrelationIdFilterTest {
private final CorrelationIdFilter filter = new CorrelationIdFilter(); private final CorrelationIdFilter filter = new CorrelationIdFilter();
@Test @Test
void preservesSafeIncomingIds() throws Exception { void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain()); CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilter(request, response, chain);
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123");
assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull();
assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull();
} }
@Test @Test
void replacesUnsafeIncomingIds() throws Exception { void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789");
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain()); filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER))
.matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
@Test
void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-00000000000000000000000000000000-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.isNotEqualTo("00000000000000000000000000000000");
}
@Test
void rejectsDuplicateAndOversizedHeaders() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129));
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
request.addHeader(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(request, response, new MockFilterChain());
assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER))
.matches("[0-9a-f-]{36}"); .matches("[0-9a-f-]{36}");
assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER))
.matches("[0-9a-f]{32}"); .matches("[0-9a-f]{32}")
.isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736")
.isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
}
private static final class CapturingFilterChain extends MockFilterChain {
private String traceId;
private String spanId;
@Override
public void doFilter(jakarta.servlet.ServletRequest request,
jakarta.servlet.ServletResponse response) throws IOException {
traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID);
spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID);
}
} }
} }
@@ -0,0 +1,37 @@
package com.baiyue.common.web;
import feign.RequestInterceptor;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
class FeignTracePropagationAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class));
@Test
void registersTraceInterceptorWhenFeignIsPresent() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class);
assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1);
});
}
@Test
void doesNotLoadFeignConfigurationWhenFeignIsAbsent() {
contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class))
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor");
});
}
@Test
void backsOffOnlyForAnApplicationProvidedTraceInterceptor() {
contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new)
.run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class));
}
}
@@ -0,0 +1,80 @@
package com.baiyue.common.web;
import feign.RequestTemplate;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import static org.assertj.core.api.Assertions.assertThat;
class FeignTraceRequestInterceptorTest {
private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor();
@AfterEach
void clearMdc() {
MDC.clear();
}
@Test
void runsLastSoTrustedContextHasFinalHeaderAuthority() {
assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE);
}
@Test
void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() {
MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123");
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
RequestTemplate template = templateWithSpoofedHeaders();
interceptor.apply(template);
assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER))
.containsExactly("req-trusted-123");
assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER))
.containsExactly("4bf92f3577b34da6a3ce929d0e0e4736");
assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER))
.singleElement().asString()
.matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
}
@Test
void createsDifferentNonZeroSpanForEachSynchronousInvocation() {
MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736");
RequestTemplate first = new RequestTemplate();
RequestTemplate second = new RequestTemplate();
interceptor.apply(first);
interceptor.apply(second);
assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER))
.isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER));
}
@Test
void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() {
MDC.put(CorrelationContext.REQUEST_ID, "invalid request id");
MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id");
RequestTemplate template = templateWithSpoofedHeaders();
interceptor.apply(template);
assertThat(template.headers()).doesNotContainKeys(
CorrelationContext.REQUEST_ID_HEADER,
CorrelationContext.TRACE_ID_HEADER,
CorrelationContext.TRACE_PARENT_HEADER);
}
private RequestTemplate templateWithSpoofedHeaders() {
RequestTemplate template = new RequestTemplate();
template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter");
template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
template.header(CorrelationContext.TRACE_PARENT_HEADER,
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00");
return template;
}
private String singleHeader(RequestTemplate template, String name) {
return template.headers().get(name).iterator().next();
}
}
@@ -12,7 +12,10 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -87,6 +90,32 @@ class GlobalExceptionHandlerTest {
.tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0); .tag("code", ResultCode.CONFLICT.code()).counter().count()).isEqualTo(1.0);
} }
@Test
void responseStatusConflictMapsToCanonicalConflictInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "stale-version");
var response = handler.handleResponseStatus(
new ResponseStatusException(HttpStatus.CONFLICT, "Order was changed; refresh and retry"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(response.getBody().message()).isEqualTo("Order was changed; refresh and retry");
assertThat(response.getBody().requestId()).isEqualTo("stale-version");
}
@Test
void missingControllerPathMapsToNotFoundInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
var response = handler.handleNoResourceFound(
new NoResourceFoundException(org.springframework.http.HttpMethod.GET,
"api/platform/v1/last-leg/dispatches"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
}
private void paging(long page) { private void paging(long page) {
} }
} }
@@ -0,0 +1,54 @@
package com.baiyue.common.web;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TraceParentTest {
@Test
void parsesCanonicalVersion00AndCreatesLocalChild() {
TraceParent remote = TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow();
TraceParent local = TraceParent.childOf(remote);
assertThat(local.traceId()).isEqualTo(remote.traceId());
assertThat(local.parentId()).matches("[0-9a-f]{16}")
.isNotEqualTo(remote.parentId())
.isNotEqualTo("0000000000000000");
assertThat(local.traceFlags()).isEqualTo("01");
assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH);
}
@Test
void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() {
assertThat(TraceParent.parse(
"00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty();
assertThat(TraceParent.parse(
"00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty();
assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty();
}
@Test
void createsCanonicalNonZeroRootAndNormalizesLegacyHex() {
TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789");
assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789");
assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000");
assertThat(root.value()).matches(
"00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00");
}
@Test
void createsSampledOutboundChildForTrustedCurrentTrace() {
TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736");
assertThat(child.value()).matches(
"00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01");
}
}
+3
View File
@@ -8,3 +8,6 @@ baiyue:
region: ${REGION_CODE:CN} region: ${REGION_CODE:CN}
deployment-cell: ${DEPLOYMENT_CELL:prod-cn} deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
data-residency: ${DATA_RESIDENCY:CN} data-residency: ${DATA_RESIDENCY:CN}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
+83
View File
@@ -21,3 +21,86 @@ baiyue:
max-rows: 200000 max-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 500 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} username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD} password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver 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.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue:
carrier-schedule:
providers:
matson-automated: ${MATSON_TRACKING_ENABLED:false}
sml-automated: ${SML_TRACKING_ENABLED:false}
zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false}
scheduler:
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
lastleg:
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}
outbox-relay:
fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
firstleg: firstleg:
inbound: inbound:
allow-rejected-measurement: false allow-rejected-measurement: false
+36
View File
@@ -21,3 +21,39 @@ baiyue:
max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3} max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3}
amazon-sp-api: amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false} enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+4
View File
@@ -8,3 +8,7 @@ spring:
baiyue.clients: baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。 # 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${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-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 500 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:} 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} 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} username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456} 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.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false}
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000}
baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M}
firstleg: firstleg:
inbound: inbound:
allow-rejected-measurement: false allow-rejected-measurement: false
@@ -15,3 +15,39 @@ baiyue:
max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50}
amazon-sp-api: amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false} enabled: ${AMAZON_SP_API_ENABLED:false}
carrier-schedule:
matson:
enabled: ${MATSON_TRACKING_ENABLED:false}
page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html}
query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php}
connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s}
hede:
enabled: ${HEDE_TRACKING_ENABLED:false}
query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic}
connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s}
sml:
enabled: ${SML_TRACKING_ENABLED:false}
query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do}
connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s}
zim:
enabled: ${ZIM_TRACKING_ENABLED:false}
authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1}
tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2}
client-id: ${ZIM_CLIENT_ID:}
client-secret: ${ZIM_CLIENT_SECRET:}
subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:}
connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s}
max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10}
evergreen:
enabled: ${EMC_TRACKING_ENABLED:false}
query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do}
connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s}
request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s}
min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s}
+4
View File
@@ -5,3 +5,7 @@ spring.datasource:
password: ${ORDER_DB_PASSWORD:123456} password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients: baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:} 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
+66
View File
@@ -0,0 +1,66 @@
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false}
audit.enabled: true
spring:
jackson:
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false
serialization:
write-dates-as-timestamps: false
data:
redis:
host: ${REDIS_HOST:redis-overseas.internal}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
discovery:
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:prod-overseas}
openfeign:
circuitbreaker.enabled: true
client.config.default:
connect-timeout: 3000
read-timeout: 5000
server:
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes.enabled: true
show-details: never
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
call-setters-on-nulls: true
global-config:
banner: false
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,21 @@
spring:
datasource:
url: ${EVENT_BRIDGE_DB_URL}
username: ${EVENT_BRIDGE_DB_USERNAME}
password: ${EVENT_BRIDGE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
region: ${REGION_CODE:OVERSEAS}
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
+13
View File
@@ -0,0 +1,13 @@
spring:
datasource:
url: ${FILE_DB_URL}
username: ${FILE_DB_USERNAME}
password: ${FILE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
file:
storage:
provider: ${FILE_STORAGE_PROVIDER:s3}
endpoint: ${FILE_STORAGE_ENDPOINT:}
bucket: ${FILE_STORAGE_BUCKET:}
access-key: ${FILE_STORAGE_ACCESS_KEY:}
secret-key: ${FILE_STORAGE_SECRET_KEY:}
@@ -0,0 +1,9 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
baiyue:
security:
overseas-projection:
service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s}
@@ -0,0 +1,12 @@
baiyue:
integration:
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:true}
client-id: ${AMAZON_SP_API_CLIENT_ID:}
client-secret: ${AMAZON_SP_API_CLIENT_SECRET:}
refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:}
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
region: ${AMAZON_SP_API_REGION:}
+42
View File
@@ -0,0 +1,42 @@
spring:
datasource:
url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
lastleg:
dispatch-query-api:
enabled: true
smart-dispatch-api:
enabled: true
baiyue:
http:
clients:
unload-photo-file:
connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s}
relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
firstleg:
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
outbox-relay:
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
@@ -0,0 +1,7 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
+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
+121
View File
@@ -0,0 +1,121 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common
restart: unless-stopped
networks: [firstleg-overseas-network, infra-network]
extra_hosts:
- "host.docker.internal:host-gateway"
environment: &overseas-environment
SPRING_PROFILES_ACTIVE: overseas
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${NACOS_USERNAME}
NACOS_PASSWORD: ${NACOS_PASSWORD}
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas}
REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal}
REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379}
REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-}
REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
JWT_ISSUER: ${JWT_ISSUER}
JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api}
JWT_JWK_SET_URI: ${JWT_JWK_SET_URI}
REGION_CODE: OVERSEAS
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
DATA_RESIDENCY: OVERSEAS
SPRING_SQL_INIT_MODE: never
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
options:
max-size: 100m
max-file: "5"
services:
gateway-overseas:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9300
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL}
PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s}
OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307}
OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s}
lastleg-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9303
LASTLEG_DB_URL: ${LASTLEG_DB_URL}
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
warehouse-overseas-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9305
WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
event-bridge-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9312
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL}
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events}
OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events}
networks:
firstleg-overseas-network:
infra-network:
aliases: [event-bridge-overseas]
overseas-security-service:
<<: *overseas-common
image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security
ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9307
OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL}
OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME}
OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD}
networks:
firstleg-overseas-network:
name: firstleg-${DEPLOY_ENV:-test}-overseas-network
infra-network:
external: true
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
+68 -12
View File
@@ -39,7 +39,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${GATEWAY_PORT:-9000}:9000"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -52,7 +52,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${AUTH_PORT:-9100}:9100"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -60,12 +60,18 @@ services:
AUTH_DB_URL: ${AUTH_DB_URL} AUTH_DB_URL: ${AUTH_DB_URL}
AUTH_DB_USERNAME: ${AUTH_DB_USERNAME} AUTH_DB_USERNAME: ${AUTH_DB_USERNAME}
AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD} 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: iam:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${IAM_PORT:-9201}:9201"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -78,7 +84,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${FIRSTLEG_PORT:-9202}:9202"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -86,12 +92,25 @@ services:
FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL}
FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME}
FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} 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: finance:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${FINANCE_PORT:-9203}:9203"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -104,7 +123,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${ORDER_PORT:-9204}:9204"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -117,7 +136,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${WAREHOUSE_PORT:-9205}:9205"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -130,7 +149,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-file container_name: firstleg-${DEPLOY_ENV:-test}-file
ports: ["${FILE_PORT:-9206}:9206"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
volumes: volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
@@ -154,7 +173,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PRINT_PORT:-9207}:9207"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -165,7 +184,7 @@ services:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${INTEGRATION_PORT:-9210}:9210"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
@@ -180,19 +199,56 @@ services:
AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-} 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_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-}
AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-} 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: event-bridge-service:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9300 SERVER_PORT: 9312
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL}
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME}
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} 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
networks:
firstleg-network:
infra-network:
aliases: [event-bridge-cn]
audit-service: audit-service:
<<: *common <<: *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} FROM ${BASE_IMAGE}
RUN addgroup -S app \ RUN addgroup -S app \
@@ -9,8 +17,11 @@ WORKDIR /app
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。 # 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs
ARG JAR_FILE # 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
COPY --chown=app:app ${JAR_FILE} /app/app.jar 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 USER app
@@ -22,4 +33,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ 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 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>
+563
View File
@@ -0,0 +1,563 @@
#!/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
if [ "$DEPLOY_ENV" = "development" ]; then
case "$SERVICE" in
gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;;
*) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;;
esac
else
PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
fi
export PUBLISHED_BIND_ADDRESS
case "$SERVICE" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $SERVICE" >&2
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|overseas-security-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
if [ "$SERVICE" = "gateway-overseas" ]; then
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
exit 2
fi
fi
if [ "$SERVICE" = "overseas-security-service" ]; then
for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
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|overseas-security-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
}
local_runtime_state() {
container_id="$(local_container_id)"
if [ -z "$container_id" ]; then
echo missing
return
fi
docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing
}
wait_for_local_health() {
elapsed=0
while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
runtime_state="$(local_runtime_state)"
health_state="$(local_health_state)"
echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)"
case "$runtime_state" in
restarting|exited|dead)
echo "容器未保持运行,立即停止等待: $runtime_state" >&2
return 1
;;
esac
case "$health_state" in
healthy)
return 0
;;
no-health:running)
echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2
return 0
;;
unhealthy|no-health:exited|no-health:dead)
return 1
;;
esac
sleep "$HEALTH_POLL_SECONDS"
elapsed=$((elapsed + HEALTH_POLL_SECONDS))
done
echo "等待容器健康超时: ${HEALTH_TIMEOUT_SECONDS}s" >&2
return 1
}
deploy_local() {
printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
trap local_registry_logout EXIT HUP INT TERM
previous_image="$(local_container_image || true)"
if [ -n "$previous_image" ]; then
echo "部署前运行镜像: $previous_image"
else
echo "首次部署,未发现可回滚的旧镜像"
fi
docker pull "$IMAGE"
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
new_started=true
if ! local_compose up -d --no-deps --pull never "$SERVICE"; then
echo "新镜像启动命令失败: $IMAGE" >&2
new_started=false
fi
if [ "$new_started" = "true" ] && wait_for_local_health; then
running_image="$(local_container_image || true)"
if [ "$running_image" != "$IMAGE" ]; then
echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2
return 1
fi
echo "新镜像健康检查通过: $IMAGE"
local_compose ps "$SERVICE"
local_registry_logout
trap - EXIT HUP INT TERM
return 0
fi
echo "新镜像健康检查失败: $IMAGE" >&2
failed_container_id="$(local_container_id || true)"
if [ -n "$failed_container_id" ]; then
echo "新镜像容器最近日志(最多 200 行):" >&2
docker logs --tail 200 "$failed_container_id" 2>&1 || true
else
echo "新镜像容器不存在,无法读取启动日志" >&2
fi
expected_prefix="$IMAGE_REPOSITORY/$SERVICE:"
case "$previous_image" in
"$expected_prefix"*)
previous_tag="${previous_image#"$expected_prefix"}"
;;
'')
echo "首次部署没有可回滚镜像,部署失败" >&2
local_compose stop "$SERVICE" >/dev/null 2>&1 || true
return 1
;;
*)
echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2
return 1
;;
esac
if [ "$previous_image" = "$IMAGE" ]; then
echo "旧镜像与新镜像相同,无法自动回滚: $IMAGE" >&2
return 1
fi
echo "开始自动回滚到: $previous_image" >&2
if ! docker pull "$previous_image"; then
echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2
return 1
fi
IMAGE_TAG="$previous_tag"
export IMAGE_TAG
if ! local_compose up -d --no-deps --pull never "$SERVICE"; then
echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2
return 1
fi
if wait_for_local_health; then
echo "自动回滚成功: $previous_image" >&2
local_compose ps "$SERVICE" || true
else
echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2
fi
return 1
}
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
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"
if [ "$deploy_env" = "development" ]; then
case "$service" in
gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;;
*) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;;
esac
else
export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}"
fi
compose() {
docker compose \
--project-name "$project_name" \
--env-file "$env_file" \
-f "$compose_file" \
"$@"
}
container_id() {
compose ps --all --quiet "$service" | head -n 1
}
container_image() {
id="$(container_id)"
if [ -n "$id" ]; then
docker inspect --format '{{.Config.Image}}' "$id"
fi
}
health_state() {
id="$(container_id)"
if [ -z "$id" ]; then
echo missing
return
fi
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$id" 2>/dev/null || echo missing
}
wait_for_health() {
elapsed=0
while [ "$elapsed" -lt "$health_timeout_seconds" ]; do
state="$(health_state)"
echo "容器健康状态: $state(已等待 ${elapsed}s)"
case "$state" in
healthy) return 0 ;;
no-health:running)
echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2
return 0
;;
unhealthy|no-health:exited|no-health:dead) return 1 ;;
esac
sleep "$health_poll_seconds"
elapsed=$((elapsed + health_poll_seconds))
done
echo "等待容器健康超时: ${health_timeout_seconds}s" >&2
return 1
}
new_image="$image_repository/$service:$image_tag"
previous_image="$(container_image || true)"
if [ -n "$previous_image" ]; then
echo "部署前运行镜像: $previous_image"
else
echo "首次部署,未发现可回滚的旧镜像"
fi
new_started=true
if ! compose up -d --no-deps --pull never "$service"; then
echo "新镜像启动命令失败: $new_image" >&2
new_started=false
fi
if [ "$new_started" = "true" ] && wait_for_health; then
running_image="$(container_image || true)"
if [ "$running_image" != "$new_image" ]; then
echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2
exit 1
fi
echo "新镜像健康检查通过: $new_image"
compose ps "$service"
exit 0
fi
echo "新镜像健康检查失败: $new_image" >&2
failed_container_id="$(remote_container_id || true)"
if [ -n "$failed_container_id" ]; then
echo "新镜像容器最近日志(最多 200 行):" >&2
docker logs --tail 200 "$failed_container_id" 2>&1 || true
else
echo "新镜像容器不存在,无法读取启动日志" >&2
fi
expected_prefix="$image_repository/$service:"
case "$previous_image" in
"$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;;
'')
echo "首次部署没有可回滚镜像,部署失败" >&2
compose stop "$service" >/dev/null 2>&1 || true
exit 1
;;
*)
echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2
exit 1
;;
esac
if [ "$previous_image" = "$new_image" ]; then
echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2
exit 1
fi
echo "开始自动回滚到: $previous_image" >&2
if ! docker pull "$previous_image"; then
echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2
exit 1
fi
IMAGE_TAG="$previous_tag"
export IMAGE_TAG
if ! compose up -d --no-deps --pull never "$service"; then
echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2
exit 1
fi
if wait_for_health; then
echo "自动回滚成功: $previous_image" >&2
compose ps "$service" || true
else
echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2
fi
exit 1
REMOTE_DEPLOY
remote_registry_logout
trap - EXIT HUP INT TERM
@@ -0,0 +1,160 @@
#!/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
export PUBLISHED_BIND_ADDRESS=127.0.0.1
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"
export PUBLISHED_BIND_ADDRESS=127.0.0.1
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
+134 -14
View File
@@ -19,6 +19,15 @@ fi
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') 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() { env_file_has_key() {
grep -q "^$1=" "$ENV_FILE" grep -q "^$1=" "$ENV_FILE"
} }
@@ -27,6 +36,70 @@ env_file_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' 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|overseas-security-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|overseas-security-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
*" overseas-security-service "*)
for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
case " $SERVICES " in
*" gateway-overseas "*)
if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then
echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2
exit 2
fi
;;
esac
case " $SERVICES " in case " $SERVICES " in
*" finance "*) *" finance "*)
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
@@ -38,6 +111,53 @@ case " $SERVICES " in
;; ;;
esac 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 case " $SERVICES " in
*" file "*) *" file "*)
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
@@ -74,25 +194,25 @@ esac
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
PULL_POLICY_ARGS="" if [ "$DEPLOY_ENV" = "development" ]; then
if [ "$DEPLOY_ENV" = "test" ]; then DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \
PULL_POLICY_ARGS="--pull never" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
else
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
pull $SERVICES
fi fi
docker compose \ docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \ --project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \ --env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \ -f "$COMPOSE_FILE" \
up -d --remove-orphans $PULL_POLICY_ARGS $SERVICES pull $SERVICES
docker compose \ docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \ --project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \ --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 ps $SERVICES
+22 -1
View File
@@ -3,6 +3,16 @@ services:
artifactId: gateway artifactId: gateway
jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar
port: 9000 port: 9000
gateway-overseas:
artifactId: gateway-overseas
jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar
port: 9300
regions: [OVERSEAS]
overseas-security-service:
artifactId: overseas-security-service
jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar
port: 9307
regions: [OVERSEAS]
auth: auth:
artifactId: auth-service artifactId: auth-service
jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar
@@ -35,10 +45,21 @@ services:
artifactId: integration-service artifactId: integration-service
jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar
port: 9210 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: event-bridge-service:
artifactId: event-bridge-service artifactId: event-bridge-service
jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar
port: 9300 port: 9312
regions: [CN, OVERSEAS]
audit-service: audit-service:
artifactId: audit-service artifactId: audit-service
jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar
+12 -2
View File
@@ -2,12 +2,14 @@
## 唯一数据库初始化与发布入口 ## 唯一数据库初始化与发布入口
- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
@@ -56,6 +58,8 @@
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。
执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。
## 三期分柜、预装、拣货与装柜当前基线 ## 三期分柜、预装、拣货与装柜当前基线
@@ -215,3 +219,9 @@ SELECT
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。 - Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。 - IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。 - 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,952 @@
-- 海外区域固定增量入口。空库先执行 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 '审核备注', review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键',
review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
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='海外采购订单';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购操作流水';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'
), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址',
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',
operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型', from_status VARCHAR(32) NULL COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键',
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='美国卡车订单状态历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1',
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1',
'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(64) NOT NULL COMMENT '具名操作类型',
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key),
KEY idx_ll_us_truck_operation_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='美国卡车订单幂等操作账';
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', 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',
idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键', change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板操作流水';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1',
'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation (
id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '操作请求内容哈希',
result_version BIGINT NOT NULL COMMENT '操作完成后的订单版本', created_by VARCHAR(128) NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key),
KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账';
-- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级;
-- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND column_name='customer_id'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
UPDATE ll_loading_order_status_history history
JOIN ll_order orders ON orders.id=history.order_id
SET history.customer_id=orders.customer_id
WHERE history.customer_id IS NULL;
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_loading_order_status_history' AND index_name='idx_ll_loading_history_customer_order'), 'SELECT 1',
'ALTER TABLE ll_loading_order_status_history ADD KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at)'));
PREPARE phase4_loading_history_customer_stmt FROM @ddl; EXECUTE phase4_loading_history_customer_stmt; DEALLOCATE PREPARE phase4_loading_history_customer_stmt;
-- 尾程订单明细实体、事件消费者和详情查询均使用 customer_id;已有开发库需从主订单回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_order_detail' AND column_name='customer_id'), 'SELECT 1',
'ALTER TABLE ll_order_detail ADD COLUMN customer_id VARCHAR(128) NULL COMMENT ''客户数据隔离键'' AFTER id'));
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
UPDATE ll_order_detail detail
JOIN ll_order orders ON orders.id=detail.order_id
SET detail.customer_id=orders.customer_id
WHERE detail.customer_id IS NULL;
ALTER TABLE ll_order_detail
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_order_detail' AND index_name='idx_ll_detail_customer_order'), 'SELECT 1',
'ALTER TABLE ll_order_detail ADD KEY idx_ll_detail_customer_order(customer_id,order_id,id)'));
PREPARE phase4_order_detail_customer_stmt FROM @ddl; EXECUTE phase4_order_detail_customer_stmt; DEALLOCATE PREPARE phase4_order_detail_customer_stmt;
-- 四期平台排车查询、智能预排与草稿锁;不开放编辑、发车、PDA 出库或派送。
CREATE TABLE IF NOT EXISTS ll_dispatch_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号',
driver_name VARCHAR(128) NULL COMMENT '司机姓名', driver_phone VARCHAR(64) NULL COMMENT '司机电话',
carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线',
depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号',
fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态',
vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态',
estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态',
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数',
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因',
created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(customer_id,dispatch_no),
KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate)
) ENGINE=InnoDB COMMENT='尾程派车单';
CREATE TABLE IF NOT EXISTS ll_dispatch_item (
id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NULL COMMENT '入仓计划ID',
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号',
order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_dispatch_item_source(dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
KEY idx_ll_dispatch_item_order(customer_id,order_id), KEY idx_ll_dispatch_item_inbound(customer_id,inbound_code),
KEY idx_ll_dispatch_item_pallet(customer_id,plan_pallet_id,warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
released_at DATETIME(3) NULL COMMENT '释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_active_pallet(customer_id,active_key),
KEY idx_ll_dispatch_pallet_lock_order(customer_id,dispatch_order_id,status),
KEY idx_ll_dispatch_pallet_lock_source(customer_id,source_pallet_key,status)
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
CREATE TABLE IF NOT EXISTS ll_dispatch_draft (
id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识',
customer_id VARCHAR(128) NULL COMMENT '客户租户ID', operator_id VARCHAR(128) NULL COMMENT '草稿操作人ID',
plan_ids_json VARCHAR(4000) NULL COMMENT '所选入仓计划ID列表JSON', containers_json LONGTEXT NULL COMMENT '草稿装车布局JSON快照',
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', expires_at DATETIME(3) NULL COMMENT '草稿失效时间',
released_at DATETIME(3) NULL COMMENT '草稿释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_dispatch_draft_token(draft_token), KEY idx_ll_dispatch_draft_operator(operator_id,status)
) ENGINE=InnoDB COMMENT='智能排车草稿';
CREATE TABLE IF NOT EXISTS ll_dispatch_draft_container (
id BIGINT NOT NULL COMMENT '主键ID', draft_id BIGINT NOT NULL COMMENT '智能排车草稿ID',
draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', sequence_no INT NOT NULL COMMENT '草稿车厢顺序号',
capacity INT NOT NULL COMMENT '草稿车厢托盘容量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
KEY idx_ll_dispatch_draft_container_token(draft_token)
) ENGINE=InnoDB COMMENT='智能排车草稿装车托盘';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'),
'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息'' AFTER order_no'));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_container_id'),
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_container_id BIGINT NULL COMMENT ''智能排车草稿装车托盘ID'' AFTER dispatch_order_id'));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'),
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER draft_container_id'));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'),
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token'));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'),
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at'));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'),
'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
PREPARE phase4_dispatch_stmt FROM @ddl; EXECUTE phase4_dispatch_stmt; DEALLOCATE PREPARE phase4_dispatch_stmt;
-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。
CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
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、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
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;
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id IS NULL OR l.customer_id='';
ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND index_name='idx_ll_inbound_line_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD KEY idx_ll_inbound_line_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
UPDATE ll_inbound_plan_pallet v JOIN ll_inbound_plan p ON p.id=v.plan_id SET v.customer_id=p.customer_id WHERE v.customer_id IS NULL OR v.customer_id='';
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND index_name='idx_ll_inbound_pallet_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD KEY idx_ll_inbound_pallet_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id SET a.customer_id=p.customer_id WHERE a.customer_id IS NULL OR a.customer_id='';
ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)'));
PREPARE phase4_receiving_scope_stmt FROM @ddl; EXECUTE phase4_receiving_scope_stmt; DEALLOCATE PREPARE phase4_receiving_scope_stmt;
DROP PROCEDURE IF EXISTS assert_phase4_inbound_plan_customer_scope_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_inbound_plan_customer_scope_ready()
BEGIN
IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id)
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id)
OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_inbound_plan_customer_scope_ready();
DROP PROCEDURE assert_phase4_inbound_plan_customer_scope_ready;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'),
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'),
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code'));
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END)
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'),
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name'));
PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'),
'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id'));
PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no'));
PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt;
UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))),
o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))),
o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL
WHERE o.current_status<>'INBOUND_COMPLETED'
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'));
DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse;
DELIMITER $$
CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse()
BEGIN
IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id
WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL
AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓';
END IF;
END$$
DELIMITER ;
CALL assert_pending_orders_do_not_use_source_warehouse();
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id'));
PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt;
DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse;
DELIMITER $$
CREATE PROCEDURE assert_active_receiving_session_has_warehouse()
BEGIN
IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL
AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话';
END IF;
END$$
DELIMITER ;
CALL assert_active_receiving_session_has_warehouse();
DROP PROCEDURE assert_active_receiving_session_has_warehouse;
ALTER TABLE ll_receiving_container_session
MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'),
'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''',
'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status'));
PREPARE phase4_receiving_sign_photos_stmt FROM @ddl; EXECUTE phase4_receiving_sign_photos_stmt; DEALLOCATE PREPARE phase4_receiving_sign_photos_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='photo_file_ids'),
'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1'));
PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt;
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=NULL,active_container_key=NULL
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
DELIMITER $$
CREATE PROCEDURE assert_receiving_global_locks_migratable()
BEGIN
IF EXISTS (SELECT operator_key FROM ll_receiving_container_session
WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜';
END IF;
IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session
WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话';
END IF;
END$$
DELIMITER ;
CALL assert_receiving_global_locks_migratable();
DROP PROCEDURE assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=CONCAT('OPERATOR:',operator_key),
active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no)))
WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL;
ALTER TABLE ll_receiving_container_session
MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键';
CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID',
line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', pallet_id BIGINT NOT NULL COMMENT '计划托盘ID',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码快照',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name VARCHAR(255) NULL COMMENT '产品名称快照',
received_packages INT NOT NULL COMMENT 'SKU实收箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code),
KEY idx_ll_receiving_sku_session (customer_id,session_id,line_id),
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable;
DELIMITER $$
CREATE PROCEDURE assert_receiving_operation_global_key_migratable()
BEGIN
IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation
GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键';
END IF;
END$$
DELIMITER ;
CALL assert_receiving_operation_global_key_migratable();
DROP PROCEDURE assert_receiving_operation_global_key_migratable;
SET @receiving_operation_global_index = (
SELECT COUNT(*) FROM (
SELECT index_name FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
AND index_name='uk_ll_receiving_operation'
GROUP BY index_name
HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key'
) ready_index
);
SET @receiving_operation_named_index = (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation'
AND index_name='uk_ll_receiving_operation'
);
SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1',
IF(@receiving_operation_named_index>0,
'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)',
'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)'));
PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
'SELECT 1',
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
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;
-- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。
CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户标识',
ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID',
container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE',
standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点',
node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号',
observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间',
actual_at DATETIME(3) NULL COMMENT '节点实际发生时间',
location_code VARCHAR(128) NULL COMMENT '节点地点编码',
location_name VARCHAR(255) NULL COMMENT '节点地点名称',
provider_code VARCHAR(64) NULL COMMENT '船节点供应商编码',
provider_event_id VARCHAR(128) NULL COMMENT '供应商事件ID',
provider_node_code VARCHAR(128) NULL COMMENT '供应商原始节点编码',
source_reference VARCHAR(255) NULL COMMENT '供应商来源引用',
raw_payload_hash VARCHAR(64) NULL COMMENT '供应商原始负载SHA-256',
aggregate_version BIGINT NOT NULL DEFAULT 0 COMMENT '来源查询聚合版本',
handover_version BIGINT NOT NULL DEFAULT 1 COMMENT '跨区交接版本',
last_event_id VARCHAR(128) NULL COMMENT '最近采纳的跨区事件ID',
payload_hash VARCHAR(71) NULL COMMENT '最近采纳事件规范化负载摘要',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(container_booking_id),
KEY idx_ll_shipping_gate_candidate(customer_id,container_no,status),
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
INSERT IGNORE INTO ll_container_shipping_gate (
container_booking_id,customer_id,ocean_booking_id,container_no,status,
node_sequence,aggregate_version,handover_version,created_at,updated_at
)
SELECT source_container_booking_id,customer_id,'',container_no,'AWAITING_PICKUP_READY',
0,COALESCE(MAX(source_loading_plan_version),0),COALESCE(MAX(source_handover_version),1),NOW(3),NOW(3)
FROM ll_order
WHERE source_container_booking_id IS NOT NULL AND source_container_booking_id <> ''
AND container_no IS NOT NULL AND container_no <> ''
GROUP BY source_container_booking_id,customer_id,container_no;
CREATE TABLE IF NOT EXISTS ll_pda_operation_history (
id BIGINT NOT NULL COMMENT 'PDA作业历史ID',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
customer_id VARCHAR(128) NULL COMMENT '作业涉及的客户标识',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识',
operation_type VARCHAR(64) NOT NULL COMMENT '作业类型',
business_key VARCHAR(255) NOT NULL COMMENT '作业业务键',
status VARCHAR(32) NOT NULL COMMENT '作业结果状态',
idempotency_key VARCHAR(255) NULL COMMENT '写操作幂等键',
request_hash VARCHAR(128) NULL COMMENT '写请求内容哈希',
summary VARCHAR(1000) NULL COMMENT '作业结果摘要',
occurred_at DATETIME(3) NOT NULL COMMENT '作业发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_history_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_history_operator(overseas_warehouse_id,operator_key,occurred_at),
KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key)
) ENGINE=InnoDB COMMENT='海外PDA真实作业历史';
CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation (
id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键',
operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED',
result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执',
last_error VARCHAR(2000) NULL COMMENT '最后失败原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_pallet_idempotency(operator_key,idempotency_key),
KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task (
id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET',
scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照',
status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数',
counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数',
created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识',
submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键',
request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no),
UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key),
KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item (
id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识',
balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照',
product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照',
book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数',
difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态',
position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识',
version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id)
) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细';
CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action (
id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希',
status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录';
CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID',
pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID',
source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID',
target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数',
position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数',
pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本',
operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因',
idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希',
status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_pda_inventory_move_no(move_no),
UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key),
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_task (
id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照',
overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID',
status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED',
operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照',
reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照',
expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数',
skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数',
completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注',
review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注',
claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间',
reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id),
UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at),
KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at)
) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标',
new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识',
action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key),
KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录';
CREATE TABLE IF NOT EXISTS ll_relabel_pda_history (
id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID',
application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态',
to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id),
KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'),
'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key'));
PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt;
-- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。
-- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。
USE baiyue_lastlag_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键',
request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID',
pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照',
before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态',
before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID',
before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本',
operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿',
reason VARCHAR(500) NULL COMMENT '操作原因', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_operation_key (operation_key),
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
'SELECT 1',
'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark'));
PREPARE phase4_warehouse_pallet_stmt FROM @ddl;
EXECUTE phase4_warehouse_pallet_stmt;
DEALLOCATE PREPARE phase4_warehouse_pallet_stmt;
SET @warehouse_pallet_fba_ready = (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet'
AND column_name='fba_warehouse_code' AND column_type='varchar(128)');
DROP PROCEDURE IF EXISTS assert_phase4_warehouse_pallet_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_warehouse_pallet_ready()
BEGIN
IF @warehouse_pallet_fba_ready <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='wh_pallet.fba_warehouse_code migration is incomplete';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_warehouse_pallet_ready();
DROP PROCEDURE assert_phase4_warehouse_pallet_ready;
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready;
DELIMITER $$
CREATE PROCEDURE assert_phase4_pda_operations_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastleg' AND table_name IN (
'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item',
'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task',
'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署';
END IF;
END$$
DELIMITER ;
CALL assert_phase4_pda_operations_ready();
DROP PROCEDURE assert_phase4_pda_operations_ready;
CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_overseas_security;
CREATE TABLE IF NOT EXISTS overseas_security_event_inbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL,
payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL,
received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL,
PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at)
) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox';
CREATE TABLE IF NOT EXISTS overseas_security_projection (
aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL,
security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL,
payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL,
changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id),
KEY idx_security_projection_permission (aggregate_id,permission_version)
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready;
DELIMITER $$
CREATE PROCEDURE assert_overseas_security_projection_ready()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security'
AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署';
END IF;
END$$
DELIMITER ;
CALL assert_overseas_security_projection_ready();
DROP PROCEDURE assert_overseas_security_projection_ready;
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
@@ -0,0 +1,150 @@
# 同步调用依赖与失败传播治理基线
## 目的与口径
本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果;
- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦;
- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用;
- `EXTERNAL`:Integration 对第三方供应商的调用。
逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。
## 当前服务边
### OpenFeign
| 调用方 | 被调用方 | 客户端数 | 主要上下文 |
|---|---|---:|---|
| Auth | IAM | 2 | 登录授权、注册,`REQUEST` |
| IAM | Auth | 1 | 账号开通/管理,`REQUEST` |
| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` |
| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` |
| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` |
| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` |
| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` |
| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` |
| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 |
| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` |
| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` |
| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 |
| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` |
| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` |
| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` |
| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` |
| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` |
| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` |
合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。
### 原始 HTTP
| 调用方 | 被调用方 | 实现 | 上下文与当前策略 |
|---|---|---|---|
| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 |
| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 |
| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 |
| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 |
| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 |
| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 |
| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 |
#### Lastleg 四个有界 RestClient
四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。
| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 |
|---|---|---|---|
| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 |
| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 |
## 分上下文依赖图
### 在线 REQUEST 图
在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。
当前在线强连通分量为:
`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File`
合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。
### 后台 BACKGROUND 图
后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为:
`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg`
File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。
### 全图
全上下文合并后,非平凡强连通分量仍为 8 个服务:
`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg`
全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。
### 用户入口在线最长简单路径
默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算:
| 用户入口 | 最长跳数 | 全部长路径 |
|---|---:|---|
| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` |
| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` |
| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` |
| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` |
| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` |
| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` |
| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` |
| 国内仓储 API | 0 | 无下游同步调用 |
| 海外仓储 API | 0 | 无下游同步调用 |
这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。
### 第一个建议退出的 REQUEST 边
建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。
## 退出顺序
1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。
2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。
3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。
4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。
5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。
6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。
7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。
8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。
## 错误、重试与熔断基线
- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。
- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。
- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。
- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。
- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。
- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。
## 直接受影响验证
| 变更范围 | 最低验证 |
|---|---|
| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` |
| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 |
| Auth/IAM | `mvn -pl auth,iam -am test` |
| Order/Firstleg | `mvn -pl order,firstleg -am test` |
| IAM/Firstleg | `mvn -pl iam,firstleg -am test` |
| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 |
| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 |
| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 |
| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` |
以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。
@@ -10,7 +10,7 @@
## 1. 项目定位 ## 1. 项目定位
`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 `baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。
当前技术基线: 当前技术基线:
@@ -24,7 +24,7 @@
- SpringDoc OpenAPI / Swagger - SpringDoc OpenAPI / Swagger
- GitLab、Jenkins、Docker Compose - GitLab、Jenkins、Docker Compose
本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。
## 2. 根目录结构 ## 2. 根目录结构
@@ -39,11 +39,16 @@ baiyue-firstleg/
├─ common-event-contract/ # 国内外跨区域事件统一契约 ├─ common-event-contract/ # 国内外跨区域事件统一契约
│ │
├─ gateway/ # 统一 API 网关 ├─ gateway/ # 统一 API 网关
├─ gateway-overseas/ # 海外区域 API 网关
├─ auth/ # 统一登录与账号凭据 ├─ auth/ # 统一登录与账号凭据
├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ iam/ # 租户、成员、角色、权限、企业审核
├─ order/ # 头程订单中心 ├─ order/ # 头程订单中心
├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ firstleg/ # 履约任务、二期入库、测量与计费
├─ warehouse/ # 仓库主数据、托盘与库位资源 ├─ warehouse/ # 仓库主数据、托盘与库位资源
├─ finance/ # 财务账期、账单、收付款与核销
├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务
├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁
├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验
├─ file/ # 资质、导出与测量图片文件中心 ├─ file/ # 资质、导出与测量图片文件中心
├─ print/ # 打印中心 ├─ print/ # 打印中心
├─ audit-service/ # 审计日志中心 ├─ audit-service/ # 审计日志中心
@@ -86,6 +91,7 @@ baiyue-firstleg/
| 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 |
| --- | --- | ---: | --- | --- | | --- | --- | ---: | --- | --- |
| `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 |
| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 |
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
@@ -94,10 +100,13 @@ baiyue-firstleg/
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `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 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 |
| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 |
| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。 MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。
## 5. 各服务文件夹说明 ## 5. 各服务文件夹说明
@@ -155,6 +164,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三
Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。
头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。
## 6. 配置、部署和辅助目录 ## 6. 配置、部署和辅助目录
### `config/` ### `config/`
@@ -165,7 +176,7 @@ config/nacos/
└─ cn/ # 国内服务器配置模板 └─ cn/ # 国内服务器配置模板
``` ```
每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 `local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。
### `deploy/` ### `deploy/`
@@ -174,7 +185,8 @@ deploy/
├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ docker/Dockerfile # 通用 Java 17 镜像
├─ env/.env.test.example # 环境变量示例 ├─ env/.env.test.example # 环境变量示例
├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本
├─ docker-compose.yml # 11 个业务服务编排 ├─ docker-compose.yml # 国内区域服务编排
├─ docker-compose.overseas.yml # 海外区域服务编排
├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施
├─ sql/ # 测试/存量环境统一迁移入口与说明 ├─ sql/ # 测试/存量环境统一迁移入口与说明
└─ service-map.yml # 模块、JAR、端口映射 └─ service-map.yml # 模块、JAR、端口映射
@@ -271,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新
- 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。
- Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。
- 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。
- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 - 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。
- 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。
- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。
+23 -19
View File
@@ -2,31 +2,35 @@
## 职责边界 ## 职责边界
- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。 - 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。 - 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。 - 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引 ## 快速索引
| 功能 | 入口 | | 功能 | 入口 |
|---|---| |---|---|
| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` | | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) | | Inbox 接收与幂等 | `service/EventInboxService.java` |
| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` | | 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload |
| 校验与 Inbox 编排 | `service/EventInboxService.java` | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 表结构 | `src/main/resources/db/event-bridge.sql` | | 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | | 测试 | `src/test/java/com/baiyue/eventbridge/` |
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。
## 修改与验证 ## 修改与验证
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。 - 定向验证:`mvn -pl event-bridge-service -am test`。
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。 - 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
- 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`。
@@ -0,0 +1,67 @@
# Event Bridge 开发阶段可观测性与 SLO 合同
## 状态与边界
本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了
Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。
指标标签只允许 `target_region`、`event_type`、`result`:
- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。
- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。
- `result` 仅为代码中冻结的接收或传输结果枚举。
- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。
## 指标合同
| Prometheus 指标 | 含义 |
|---|---|
| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` |
| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` |
| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 |
| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 |
| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 |
| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 |
`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数,
不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。
Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge,
不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有
`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。
## 开发阶段 SLI 与 SLO
SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT`
不重复进入分母。滚动 30 天目标:
- 99% 的新接收跨区事件在 60 秒内得到下游确认。
- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。
代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组:
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99
```
```promql
sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d]))
/
sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999
```
## 告警合同
规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同:
- 新增 DEAD:下一次评估立即告警,不设置持续等待。
`sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0`
- 最老待处理年龄预警:任一有限标签序列超过 120 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120`
- 最老待处理年龄严重:任一有限标签序列超过 900 秒。
`max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900`
- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。
告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。
@@ -1,14 +1,19 @@
package com.baiyue.eventbridge; package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 跨区域事件桥启动入口。 * 跨区域事件桥启动入口。
*/ */
@MapperScan("com.baiyue.eventbridge.mapper") @MapperScan("com.baiyue.eventbridge.mapper")
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication { public class EventBridgeApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(EventBridgeApplication.class, args); SpringApplication.run(EventBridgeApplication.class, args);

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