Compare commits

...
1339 Commits
Author SHA1 Message Date
wujinliang 263068ac85 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-18 09:16:10 +08:00
wujinliang 6be4e764a9 1 2026-09-18 09:16:02 +08:00
liuyihe 29f05b7b09 贴标申请,仓储转运 2026-09-17 17:37:29 +08:00
ouyangxq 3b8b6f5aa2 ci: remove duplicate production deploy approval 2026-09-17 16:01:00 +08:00
ouyangxq 9dc344bdef test(sql): align contracts with clean initialization 2026-09-17 15:10:27 +08:00
ouyangxq 1fe99eb6d0 fix(iam): grant merchant return inventory permission 2026-09-17 15:10:27 +08:00
wujinliang 522afd0185 1 2026-09-17 15:05:02 +08:00
wujinliang 66c68cdc89 1 2026-09-17 14:40:02 +08:00
ouyangxq 6ffd56a0a3 test(auth): align SQL contracts with clean initialization 2026-09-17 13:59:31 +08:00
ouyangxq cd16687d25 ci: 收口生产 main 分支部署流程 2026-09-17 11:49:00 +08:00
ouyangxq bf8256a817 test: 标记物理箱全链路验收完成 2026-09-17 10:21:46 +08:00
ouyangxq 9ab1c328ba fix: 支持多SKU共箱整托装柜 2026-09-16 21:22:18 +08:00
ouyangxq 1cd10a9d3b test: 对齐海外空库初始化合同 2026-09-16 21:06:00 +08:00
ouyangxq c6797a8aad fix: 恢复海外服务 Nacos 凭据注入 2026-09-16 20:58:52 +08:00
ouyangxq 7ce662949b 优化 2026-09-16 20:50:10 +08:00
ouyangxq 545ad2083d fix: 按物理箱贯通一件代发履约箱数 2026-09-16 20:49:35 +08:00
ouyangxq 8797c0bef9 优化 2026-09-16 19:32:52 +08:00
ouyangxq dfcd280dd7 优化 2026-09-16 17:52:02 +08:00
ouyangxq 053e59bec1 优化 2026-09-16 17:49:27 +08:00
ouyangxq 8488d4d989 优化 2026-09-16 17:49:20 +08:00
ouyangxq 392f5b7386 优化 2026-09-16 17:15:18 +08:00
ouyangxq 854d484142 优化 2026-09-16 16:42:05 +08:00
ouyangxq b79cd9ec42 优化 2026-09-16 16:17:38 +08:00
ouyangxq 4571e3ac16 优化 2026-09-16 15:57:37 +08:00
ouyangxq 8f0622058d 优化 2026-09-16 15:21:08 +08:00
ouyangxq 912feca56f 优化 2026-09-16 12:02:38 +08:00
ouyangxq de29e639e4 fix: check released bookings only for active loading allocations 2026-09-16 11:50:30 +08:00
ouyangxq 35c6c6ec03 优化 2026-09-16 11:43:02 +08:00
ouyangxq dcd8cc2c85 test: cover loading reassignment and PDA completion gates 2026-09-16 11:40:57 +08:00
ouyangxq 011aa80870 fix: ignore empty loading links during completion 2026-09-16 11:33:16 +08:00
ouyangxq a975b07968 优化 2026-09-16 11:14:32 +08:00
ouyangxq a098c4377c 优化 2026-09-16 11:06:33 +08:00
ouyangxq a4c659f4c6 fix: count distinct SKU codes across shared pallet inbounds 2026-09-16 10:18:56 +08:00
ouyangxq c099b19176 完善一件代发整托拣货回归 2026-09-15 21:05:08 +08:00
ouyangxq 8b19085bc4 优化 2026-09-15 20:52:02 +08:00
ouyangxq 4b671bb0ab 优化 2026-09-15 18:17:06 +08:00
ouyangxq 45018987ef fix(return): replay concurrent merges and extend integrity acceptance 2026-09-15 18:00:08 +08:00
ouyangxq 6b0de57a4b test(return): cover isolation and concurrency; fix receipt and packing races 2026-09-15 17:05:07 +08:00
ouyangxq 28fa2a001d 优化 2026-09-15 16:39:44 +08:00
ouyangxq c207a4d3b7 优化 2026-09-15 16:10:55 +08:00
liuyihe 506525524b Merge remote-tracking branch 'origin/test' into test 2026-09-15 15:28:40 +08:00
ouyangxq fd86c1b801 优化 2026-09-15 15:25:05 +08:00
liuyihe cadc5a175e 提交 2026-09-15 15:15:46 +08:00
ouyangxq 6d5cc03741 优化 2026-09-15 15:13:15 +08:00
ouyangxq 7edea222d8 test: synchronize initialization and dependency contracts 2026-09-15 14:58:26 +08:00
ouyangxq c65389b2c2 fix: preserve receiving session on duplicate pallet scans 2026-09-15 14:44:10 +08:00
ouyangxq 791918c0dc 修正排车路线字段合同 2026-09-15 14:36:43 +08:00
ouyangxq d1a00b8e01 优化 2026-09-15 14:30:47 +08:00
ouyangxq a474ca4d93 优化 2026-09-15 14:18:18 +08:00
ouyangxq 6a2075fe4b 优化 2026-09-15 13:43:12 +08:00
ouyangxq f9649c44e9 优化 2026-09-15 13:36:58 +08:00
ouyangxq 1bb69698de fix: expose completed unified order status 2026-09-15 13:12:17 +08:00
ouyangxq 960b5d6b0a test: retain a second FBA fixture for delivery split 2026-09-15 13:01:44 +08:00
ouyangxq 9914d40593 test: prove dispatch split across FBA destinations 2026-09-15 12:54:09 +08:00
ouyangxq a7a6d78568 fix: persist overseas delivery completion history 2026-09-15 12:47:45 +08:00
ouyangxq 13ff7e1513 test: use supported delivery vehicle size 2026-09-15 12:34:47 +08:00
ouyangxq 2c183f0def test: use supported dispatch vehicle capacity 2026-09-15 12:29:47 +08:00
ouyangxq 9e53a0d11f test: use channel groups in booking fixtures 2026-09-15 12:24:28 +08:00
ouyangxq 9888072d8c fix: limit dispatch vehicles to 15 and 30 pallets 2026-09-15 12:17:16 +08:00
ouyangxq 37c3e676a4 test: remove manual delivery completion permission contract 2026-09-15 12:12:57 +08:00
ouyangxq b2c5bafb8e fix: remove unused pallet carton mark override 2026-09-15 12:08:11 +08:00
ouyangxq a400563552 test: honor runtime inbound code policy in HTTP order fixtures 2026-09-15 12:07:43 +08:00
ouyangxq 6e35993e61 更新派送自动完成测试验收 2026-09-15 12:07:10 +08:00
ouyangxq c1f01e1e87 优化 2026-09-15 11:54:14 +08:00
ouyangxq 3fb9909dd3 test(lastleg): align relabel upload fixture and PDA status comment 2026-09-15 11:45:40 +08:00
ouyangxq 3ebe254c45 fix(dropship): serialize outbound creation and pick exact carton subsets 2026-09-15 11:42:19 +08:00
ouyangxq 88e2fb0eb1 优化 2026-09-15 10:37:34 +08:00
liuyihe 419950482f Merge remote-tracking branch 'origin/test' into test 2026-09-14 22:44:54 +08:00
ouyangxq 4f13ee2ec3 fix(dropship): preserve carton quantities and shared pallet outbound ownership 2026-09-14 20:55:09 +08:00
liuyihe 70145e6b3b 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 20:21:07 +08:00
ouyangxq 6dbcb2128c 优化 2026-09-14 20:20:57 +08:00
ouyangxq ebc299d58a 优化 2026-09-14 20:03:22 +08:00
ouyangxq d53de922db 优化 2026-09-14 18:34:37 +08:00
ouyangxq d9a4384585 fix(firstleg): align measurement package validation 2026-09-14 18:32:32 +08:00
ouyangxq 3910b8fcce feat: finalize dropship pallet print documents 2026-09-14 17:52:01 +08:00
ouyangxq b3d74c5441 test: align full-chain fixture invariants 2026-09-14 17:45:56 +08:00
ouyangxq e26b39ba0a test: use current loading remarks endpoint 2026-09-14 17:36:52 +08:00
ouyangxq a9959620f1 test: target unified order remarks endpoint 2026-09-14 17:31:06 +08:00
ouyangxq 6f917f9b2a Merge remote-tracking branch 'origin/test' into test 2026-09-14 17:29:27 +08:00
ouyangxq 4c048d0760 test: use current order remarks contract 2026-09-14 17:29:03 +08:00
liuyihe dbc4926d14 Merge remote-tracking branch 'origin/test' into test 2026-09-14 17:26:01 +08:00
ouyangxq 64bddb1f81 fix: scope smart loading recommendations 2026-09-14 17:22:09 +08:00
ouyangxq 8a1e587ee4 fix(order): align FBA submit and fee confirmation 2026-09-14 17:19:06 +08:00
liuyihe 57358d7665 Merge branch 'test' into dev 2026-09-14 17:04:24 +08:00
ouyangxq e0af45db02 feat: add editable order collaboration remarks 2026-09-14 16:53:09 +08:00
liuyihe 57f62ae97b 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 16:49:08 +08:00
liuyihe 78bbd69a26 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 16:49:00 +08:00
ouyangxq 26c37deb55 fix: enforce dropship pallet count for inbound plans 2026-09-14 16:38:50 +08:00
ouyangxq 99f2c730e0 优化 2026-09-14 15:53:49 +08:00
ouyangxq f9fa859391 优化 2026-09-14 15:51:52 +08:00
ouyangxq 13d2fe8e3a fix: merge multi-pallet dropship handover fragments 2026-09-14 15:44:51 +08:00
ouyangxq fe888f9278 fix(iam): scope customer nickname to customer management 2026-09-14 15:38:29 +08:00
ouyangxq 059757507a 优化 2026-09-14 15:33:45 +08:00
ouyangxq cdac78e4f7 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-14 15:33:30 +08:00
ouyangxq 46b4d14898 优化 2026-09-14 15:33:29 +08:00
liuyihe cd578a8615 优化提交 2026-09-14 15:26:00 +08:00
liuyihe 60ebc2e376 优化提交 2026-09-14 15:03:04 +08:00
ouyangxq febe265c50 fix: tolerate stale pallets in smart recommendation 2026-09-14 15:01:05 +08:00
ouyangxq 1d258ab27b fix: keep dropship pallets atomic in smart loading 2026-09-14 14:35:29 +08:00
ouyangxq de9516d19e test: align client registration replay with current auth contract 2026-09-14 13:49:35 +08:00
ouyangxq d8254c4f1d 优化 2026-09-14 13:48:21 +08:00
ouyangxq d8e2dc59ae fix: validate transfer rejection and align dispatch HTTP contracts 2026-09-14 13:46:32 +08:00
ouyangxq a2ea0ba9e6 fix: align HTTP acceptance fixtures and pallet permissions 2026-09-14 12:40:40 +08:00
ouyangxq e55ab73e61 优化 2026-09-14 12:00:48 +08:00
ouyangxq fb66d9200a fix: give warehouse transfer approval a distinct permission id 2026-09-14 11:35:14 +08:00
ouyangxq 54f5bdfb1f fix: align overseas administrator authority assertion with merged baseline 2026-09-14 11:26:16 +08:00
ouyangxq 035537e5cc 优化 2026-09-14 11:12:29 +08:00
ouyangxq 481ab348ff Merge latest origin/dev into test retaining verified acceptance workflows 2026-09-14 11:05:33 +08:00
ouyangxq d4fec8294d Merge origin/dev into test preserving warehouse and PDA workflows 2026-09-14 11:03:47 +08:00
liuyihe 6c7bc1dc97 Merge test into dev and resolve conflicts 2026-09-14 11:02:37 +08:00
liuyihe f7ef8be18f 优化提交 2026-09-14 10:37:23 +08:00
liuyihe 8cf7c4b9c9 优化提交 2026-09-14 09:58:30 +08:00
ouyangxq cc2aed72d9 优化 2026-09-12 18:34:23 +08:00
ouyangxq f26e4b2972 fix(lastleg): trace return carton sources and generate scannable labels 2026-09-12 16:16:38 +08:00
ouyangxq e467d60e8f fix(lastleg): allow edits with historical procurement warehouse 2026-09-12 16:14:50 +08:00
ouyangxq d33922ad35 fix overseas return inventory flow 2026-09-12 13:55:48 +08:00
ouyangxq 2191bacbfe fix qualification upload contract and test baseline 2026-09-12 12:27:56 +08:00
ouyangxq dcd079b5b7 优化 2026-09-11 18:43:57 +08:00
ouyangxq 06b9f3eda7 fix: persist PDA picking progress incrementally 2026-09-11 18:24:18 +08:00
ouyangxq 019db2900a 优化 2026-09-11 18:14:29 +08:00
ouyangxq 30d6896c8a docs: clarify scan-first pallet workflow 2026-09-11 17:29:05 +08:00
ouyangxq 63ce4c48a7 优化 2026-09-11 17:27:27 +08:00
ouyangxq 458a4cfa50 优化 2026-09-11 17:20:56 +08:00
ouyangxq c8e8245853 feat: allow shared dropship pallets 2026-09-11 17:12:03 +08:00
ouyangxq 1683977e0b 优化 2026-09-11 17:01:56 +08:00
ouyangxq b6c66565b9 fix: unify same-carton order metrics 2026-09-11 16:50:10 +08:00
ouyangxq 87ae82a114 test: support manual dropship count fixtures 2026-09-11 16:34:40 +08:00
ouyangxq 010475a4c3 fix: prepare mixed-SKU dropship acceptance data 2026-09-11 16:21:14 +08:00
ouyangxq 51ce609ab1 优化 2026-09-11 16:17:16 +08:00
ouyangxq 2d928d667b fix: resolve cross-plan pallet allocations 2026-09-11 16:10:49 +08:00
ouyangxq 64fa620c19 test: add full return receipt acceptance 2026-09-11 16:02:10 +08:00
ouyangxq e1319cb389 fix: keep unified SQL pass marker final 2026-09-11 15:50:17 +08:00
ouyangxq 071f807b87 fix: aggregate mixed cartons by fulfillment type 2026-09-11 15:45:05 +08:00
ouyangxq 774f0472e8 feat: explain supplemental pallet counts 2026-09-11 15:39:38 +08:00
ouyangxq cc7c2f0954 feat: support mixed-SKU dropship cartons 2026-09-11 15:38:19 +08:00
ouyangxq 0db11517db fix: persist return forecast tail references 2026-09-11 15:10:01 +08:00
ouyangxq 183b242d45 test: cover PDA return receipt lifecycle 2026-09-11 15:01:17 +08:00
ouyangxq 3b97eedc3d fix: block dropship work until pallets are ready 2026-09-11 13:54:06 +08:00
ouyangxq 137f8173ab fix: keep full-chain acceptance aligned with current APIs 2026-09-11 12:36:12 +08:00
ouyangxq 1f20516d23 docs: update dropship pallet acceptance flow 2026-09-11 12:27:41 +08:00
ouyangxq b57ca7def7 fix: align dropship acceptance with precreated pallets 2026-09-11 12:26:52 +08:00
ouyangxq e61050c490 feat: precreate dropship pallet codes after measurement 2026-09-11 12:12:41 +08:00
ouyangxq 69076ea712 fix: print overseas warehouse address on BOL 2026-09-11 11:55:46 +08:00
ouyangxq 749d403ed4 优化 2026-09-11 11:52:41 +08:00
ouyangxq d2cff49b9e 优化 2026-09-11 11:51:26 +08:00
ouyangxq d60689ce02 feat: match warehouse BOL print layout 2026-09-11 11:35:37 +08:00
ouyangxq 7fd2e70024 优化 2026-09-11 11:15:01 +08:00
ouyangxq a4b7247c57 fix: expose overseas receiving business facts 2026-09-11 10:32:30 +08:00
ouyangxq b2f85501ac fix: connect overseas lastleg to print service 2026-09-11 10:24:08 +08:00
ouyangxq 220d159cec feat: generate dispatch BOL PDFs 2026-09-11 10:09:06 +08:00
ouyangxq 81f4f9163c fix: link terminal drayage carrier events 2026-09-11 02:20:50 +08:00
ouyangxq fe278f2ba6 test: sequence terminal drayage around unloading 2026-09-11 01:50:46 +08:00
ouyangxq abbacc683e test: align acceptance and dependency contracts 2026-09-11 01:31:14 +08:00
ouyangxq 543ebee5cc test: refresh merchant token before PDA handoff 2026-09-11 01:24:00 +08:00
ouyangxq 4f27e33daf test: align booking channel group contract 2026-09-11 01:20:21 +08:00
ouyangxq 3209b2e647 test: follow complete relabel lifecycle 2026-09-11 01:18:01 +08:00
ouyangxq 7dff9b57ed fix: reuse overseas receiving resources in acceptance 2026-09-11 00:48:21 +08:00
ouyangxq 9abb0004dd test: align overseas PDA group receipt semantics 2026-09-11 00:24:35 +08:00
ouyangxq 2bd12f8a5f fix: converge inbound pallets and retries 2026-09-11 00:06:57 +08:00
ouyangxq ed12769858 fix: converge dropship manifest schema 2026-09-10 23:48:48 +08:00
ouyangxq 4294a5c3db 优化 2026-09-10 23:27:49 +08:00
ouyangxq 89573fd1f0 fix: make unified database migrations converge 2026-09-10 23:12:37 +08:00
ouyangxq 56635ad4e4 feat: complete finance and overseas receiving workflow 2026-09-10 22:43:11 +08:00
ouyangxq e531ae9407 fix(acceptance): require FBA pallet label configuration 2026-09-10 21:53:15 +08:00
ouyangxq 6fa2260572 fix(acceptance): derive split assertions from real cargo 2026-09-10 21:29:40 +08:00
ouyangxq e3dc5329aa fix(acceptance): reuse configured merchant credentials 2026-09-10 21:26:52 +08:00
ouyangxq 6cf144e53d feat: expose dispatch BOL lines 2026-09-10 21:22:06 +08:00
ouyangxq 800ebb875f fix(acceptance): enforce immutable confirmed fees 2026-09-10 21:18:52 +08:00
ouyangxq 7b35373da3 fix(firstleg): enforce PDA task warehouse scope 2026-09-10 20:47:23 +08:00
ouyangxq 47a70406f6 fix(acceptance): reuse initialized pallet fixtures 2026-09-10 20:47:15 +08:00
ouyangxq 69e80a736e fix(sql): make domestic migration replay-safe 2026-09-10 20:19:11 +08:00
ouyangxq 2b73245c96 test(firstleg): align workbench channel group contract 2026-09-10 19:38:40 +08:00
ouyangxq 07b5f6e077 test(iam): align migration guide contract 2026-09-10 19:34:40 +08:00
ouyangxq c480f957c5 优化 2026-09-10 19:27:09 +08:00
ouyangxq 155dd9d77d feat(firstleg): align simulated containers with channel groups 2026-09-10 19:24:49 +08:00
ouyangxq 5ffd6c0118 fix: require configured overseas service tokens 2026-09-10 18:22:42 +08:00
ouyangxq 959d43e308 Merge remote-tracking branch 'origin/dev' into dev 2026-09-10 18:19:10 +08:00
ouyangxq 2fc2fca9fd Merge test fixes into dev dropship flow 2026-09-10 18:18:41 +08:00
liuyihe 72e62c0532 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
#	lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
#	scripts/README.md
2026-09-10 17:25:07 +08:00
ouyangxq 56d555668d 优化 2026-09-10 17:13:02 +08:00
liuyihe 2e6a18c58e 优化 2026-09-10 17:11:33 +08:00
ouyangxq d37e6cf147 fix: auto materialize completed container plans 2026-09-10 17:05:40 +08:00
ouyangxq 343c6e278a fix: block receiving before pallet materialization 2026-09-10 16:43:08 +08:00
ouyangxq 1ce99e710d fix: materialize signed manual pallet plans 2026-09-10 16:33:32 +08:00
ouyangxq f5d6c37080 fix: deduplicate phase5 overseas schema baseline 2026-09-10 15:49:20 +08:00
ouyangxq f0b8514890 fix: materialize container pallet plans 2026-09-10 15:40:55 +08:00
ouyangxq a5db6fbbb6 优化 2026-09-10 15:14:30 +08:00
ouyangxq 2963acc32b fix(lastleg): return empty container customer options 2026-09-10 14:41:03 +08:00
ouyangxq bf7579f93b 优化 2026-09-10 14:38:22 +08:00
ouyangxq 6d0cd3e301 优化 2026-09-10 14:16:24 +08:00
ouyangxq 0dc0053787 优化 2026-09-10 14:15:41 +08:00
ouyangxq 3e4d6b76aa fix(events): allow container nodes before ocean booking 2026-09-10 14:10:02 +08:00
ouyangxq c026edbb2f fix(firstleg): allow customs view for empty loading plan 2026-09-10 14:02:26 +08:00
ouyangxq a3890e7a55 fix(events): allow manual nodes without tracking query 2026-09-10 13:56:07 +08:00
ouyangxq c051be3a67 fix(gateway): route container shipping nodes 2026-09-10 13:45:41 +08:00
ouyangxq 98f61ba3ed 优化 2026-09-10 12:18:33 +08:00
ouyangxq 443b01c8a8 fix(firstleg): decouple manual container nodes from tracking 2026-09-10 12:17:45 +08:00
ouyangxq c1a36c6f40 fix(firstleg): enable manual nodes for consolidated containers 2026-09-10 11:55:09 +08:00
ouyangxq 2821c5e9c7 fix(firstleg): update submitted outer dimensions safely 2026-09-10 11:42:05 +08:00
ouyangxq e8890389ff fix(firstleg): keep PDA photos accessible after departure 2026-09-10 11:37:51 +08:00
ouyangxq 1ef289cd09 优化 2026-09-10 11:25:16 +08:00
ouyangxq e9aa2ac047 优化 2026-09-10 11:18:31 +08:00
ouyangxq 8731efcbea fix(firstleg): allow fee revision until final fulfillment lock 2026-09-10 11:02:45 +08:00
ouyangxq a926a23b87 fix: allow overseas container detail before carrier tracking 2026-09-10 10:59:09 +08:00
ouyangxq 630150a7c7 fix: allow unassigned overseas receiving warehouse 2026-09-10 10:34:24 +08:00
ouyangxq e72e74b21e fix: stabilize overseas initialization and PDA acceptance 2026-09-10 10:25:16 +08:00
ouyangxq 37a28b9c11 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/resources/db/lastleg.sql
2026-09-10 10:01:35 +08:00
ouyangxq 87e65043f3 fix: treat overseas unload photos as platform evidence 2026-09-10 00:18:17 +08:00
ouyangxq 3e4b9d0a9a 优化 2026-09-09 23:46:21 +08:00
ouyangxq 8487eeb1b0 merge: sync latest test environment changes 2026-09-09 22:45:42 +08:00
ouyangxq 107374bec5 fix: align overseas container scope migration 2026-09-09 21:37:01 +08:00
ouyangxq a05b263ca8 fix: support multi-customer overseas receiving containers 2026-09-09 21:30:39 +08:00
ouyangxq c8ab4d3de5 merge: sync test and stabilize full acceptance 2026-09-09 21:14:25 +08:00
ouyangxq 6737c4c327 优化 2026-09-09 20:53:24 +08:00
ouyangxq eac6875877 test: stabilize full dev acceptance orchestration 2026-09-09 20:36:53 +08:00
ouyangxq 71604c9c39 fix: deliver actual destuff pallet fees 2026-09-09 20:00:44 +08:00
ouyangxq b3e0ea9199 fix: complete dropship container after pallet receipt 2026-09-09 19:41:29 +08:00
ouyangxq f3bbe0e15f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 19:40:30 +08:00
ouyangxq 5ccce6af71 test: carry sealed pallet into overseas acceptance 2026-09-09 19:25:13 +08:00
ouyangxq 4cc3e5372b fix: allow native pallets for overseas destinations 2026-09-09 19:24:10 +08:00
ouyangxq e0d8b4f76a Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-tail-order-controls.mjs
2026-09-09 19:23:57 +08:00
ouyangxq 90a0b009da test: keep overseas acceptance fixtures independent 2026-09-09 19:16:18 +08:00
ouyangxq e30255ff58 fix: complete pallet code cross-region flow 2026-09-09 19:14:47 +08:00
ouyangxq 30b9db9628 test: align tail fee acceptance with current contract 2026-09-09 19:11:37 +08:00
ouyangxq 1e66fc6c22 fix: enforce dropship whole-pallet receiving 2026-09-09 19:07:13 +08:00
ouyangxq 3e01dec4f8 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 19:05:05 +08:00
ouyangxq 7678fd85ff test: cover customer inbound fee confirmation 2026-09-09 18:43:08 +08:00
ouyangxq d9603aa7da 修复统一增量中的IAM权限区块边界 2026-09-09 18:38:22 +08:00
ouyangxq 1c4f35f61c test: verify manual inbound unit price flow 2026-09-09 18:34:07 +08:00
ouyangxq 0d016bc5fc 适配财务验收FBA内部编号 2026-09-09 18:33:39 +08:00
ouyangxq 45e7c98b3f feat: receive dropship inbound by source pallet 2026-09-09 18:33:17 +08:00
ouyangxq c8ae626e87 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 18:31:14 +08:00
ouyangxq acf9bd7135 优化 2026-09-09 18:29:42 +08:00
ouyangxq 1dfb19276c Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java
2026-09-09 18:20:40 +08:00
ouyangxq 312501cc70 feat: use FBA internal code in pallet labels 2026-09-09 18:19:58 +08:00
ouyangxq 2a9f79604b 修复装柜完成托盘标签事务 2026-09-09 18:18:26 +08:00
ouyangxq 3b02affcb3 优化 2026-09-09 17:58:07 +08:00
ouyangxq 4fee718424 修复仓库内部编号增量迁移 2026-09-09 17:55:46 +08:00
ouyangxq fc5ce1f45f 完善财务P1验收客户规则快照 2026-09-09 17:46:36 +08:00
ouyangxq be882d53d1 优化 2026-09-09 17:42:44 +08:00
ouyangxq 88b489eb77 优化 2026-09-09 17:42:26 +08:00
ouyangxq 2b0e66ca7e test: cover pallet label loading-list paths 2026-09-09 17:35:47 +08:00
ouyangxq de1f7e8ddd feat: freeze and propagate first-leg pallet labels 2026-09-09 17:27:00 +08:00
ouyangxq 6f0622ed36 fix: close development regression gaps 2026-09-09 17:16:34 +08:00
ouyangxq 7c6fa248ac Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
2026-09-09 17:07:34 +08:00
ouyangxq 48bc8bf0c1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
2026-09-09 16:49:46 +08:00
ouyangxq 83e95d764e 修复海外业务接口契约格式 2026-09-09 16:49:01 +08:00
ouyangxq 7113703e3c 更新财务费用状态合同 2026-09-09 16:46:01 +08:00
ouyangxq 17b9f3eb73 统一财务费用来源入池链路 2026-09-09 16:39:20 +08:00
ouyangxq 95828a0ec6 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 15:41:50 +08:00
ouyangxq 9fdb199972 修复导出基础设施测试的三次重启策略断言 2026-09-09 15:41:32 +08:00
ouyangxq 2980308f4d 优化 2026-09-09 15:27:10 +08:00
liuyihe 1ee32b7a6f 优化 2026-09-09 15:14:15 +08:00
ouyangxq 5f671e164f 优化 2026-09-09 14:31:59 +08:00
ouyangxq 250b623f3a 限制容器异常退出后最多自动重启三次 2026-09-09 14:26:34 +08:00
ouyangxq 3727ff141c 补齐码头车队邮件表字段中文注释 2026-09-09 14:00:03 +08:00
ouyangxq f31a049444 优化 2026-09-09 13:54:47 +08:00
ouyangxq 1a092bc9d7 优化 2026-09-09 13:51:01 +08:00
liuyihe 90a2eda86a 优化 2026-09-09 13:41:24 +08:00
ouyangxq 748855793d fix(dropship): project sealed carton identities into overseas receiving 2026-09-09 13:20:11 +08:00
ouyangxq 90973532f0 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 13:13:51 +08:00
ouyangxq a3354c92c2 优化 2026-09-09 12:57:59 +08:00
ouyangxq 655237e37f fix(lastleg): complete relabel document contract and regression gates 2026-09-09 12:54:14 +08:00
ouyangxq ac3ce3703e fix(firstleg): preserve sealed pallet identity in atomic PDA picking 2026-09-09 12:33:31 +08:00
ouyangxq 44ac24a9e2 Merge remote-tracking branch 'origin/dev' into dev 2026-09-09 12:20:05 +08:00
ouyangxq 0d8539e72f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 12:19:17 +08:00
ouyangxq 5fb147f7aa fix(dropship): generate pallet numbers atomically and extend full regressions 2026-09-09 12:19:16 +08:00
liuyihe c3ae15abfe 优化 2026-09-09 12:02:12 +08:00
ouyangxq e5d826b4a2 优化 2026-09-09 11:55:06 +08:00
ouyangxq 465d9dfc10 feat(lastleg): manage three delivery document categories 2026-09-09 11:22:47 +08:00
ouyangxq f37e9ce555 fix(firstleg): allow optional ocean booking customer 2026-09-09 11:21:37 +08:00
ouyangxq 81b729929c fix(file): converge delivery documents to three categories 2026-09-09 11:21:36 +08:00
ouyangxq 156520b560 fix(file): make ocean booking documents platform owned 2026-09-09 11:16:28 +08:00
ouyangxq 3627239708 feat(file): support seven delivery document categories 2026-09-09 11:11:32 +08:00
ouyangxq cde8acf111 feat(iam): add delivery document management permission 2026-09-09 11:09:51 +08:00
ouyangxq 0d157e4dd9 test(firstleg): verify overseas list return action projection 2026-09-09 10:52:53 +08:00
ouyangxq 5ecee65720 test(finance): cover overseas fee replay and frozen lifecycle 2026-09-09 10:20:56 +08:00
ouyangxq 87d6469f02 优化 2026-09-09 10:18:04 +08:00
ouyangxq 7e2626513e fix(finance): preserve container identity and source version order 2026-09-09 10:14:42 +08:00
ouyangxq 4379b721bc 优化 2026-09-09 09:52:46 +08:00
ouyangxq 9d492cb5ed fix(overseas): restore dispatch delivery returns 2026-09-09 04:00:24 +08:00
ouyangxq 646221d133 test(overseas): use authoritative fulfillment fixtures 2026-09-09 03:42:48 +08:00
ouyangxq bfcec93017 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 03:40:54 +08:00
ouyangxq 68a5612598 fix(firstleg): preserve dropship palletization state during pricing 2026-09-09 03:22:07 +08:00
ouyangxq a99a54493b test(overseas): close fulfillment HTTP gaps 2026-09-09 03:18:59 +08:00
ouyangxq 47774ea8fe Merge remote-tracking branch 'origin/test' into dev 2026-09-09 03:16:56 +08:00
ouyangxq bf89a04883 fix(firstleg): price dropship before preload confirmation 2026-09-09 03:11:41 +08:00
ouyangxq cda6521078 test(overseas): align relabel and pallet acceptance 2026-09-09 03:02:36 +08:00
ouyangxq 71580da8d7 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-09 03:00:48 +08:00
ouyangxq c5862db98d fix(lastleg): complete generated pallet receiving flow 2026-09-09 02:54:09 +08:00
ouyangxq 045d0d815b test(overseas): cover split relabel inventory 2026-09-09 02:48:10 +08:00
ouyangxq 41225248d0 test(acceptance): reconcile dropship runtime facts 2026-09-09 02:40:42 +08:00
ouyangxq 4ccdae0665 fix(overseas): price generated receiving pallets 2026-09-09 02:17:38 +08:00
ouyangxq 66ba59a51a test(firstleg): accept pallet pick audit action 2026-09-09 01:57:15 +08:00
ouyangxq 96b5f66d5c Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
2026-09-09 01:56:29 +08:00
ouyangxq 86646ccd69 test(admin): scope domestic container lookup 2026-09-09 01:54:54 +08:00
ouyangxq 04c056f744 test(admin): unwrap container workbench details 2026-09-09 01:52:59 +08:00
ouyangxq 74fa4789f5 fix(acceptance): permit dropship pallet pick audit 2026-09-09 01:50:19 +08:00
ouyangxq c5d73b8e8f test(admin): use canonical container workbench routes 2026-09-09 01:49:52 +08:00
ouyangxq decfe4ab1f Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
2026-09-09 01:48:32 +08:00
ouyangxq 859fba4ecc test(admin): confirm fee adjustment tolerance 2026-09-09 01:46:11 +08:00
ouyangxq a0b1c761b7 fix(firstleg): complete inbound fee deployment schema 2026-09-09 01:33:03 +08:00
ouyangxq f7e3863898 fix(acceptance): follow reserved pallet workflows 2026-09-09 01:30:22 +08:00
ouyangxq 3cfd45f4bb fix(lastleg): preserve generated pallet FBA destination 2026-09-09 01:29:38 +08:00
ouyangxq b11ad31d5f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 01:28:48 +08:00
ouyangxq b797af706a test(admin): sync fee item baseline 2026-09-09 01:27:44 +08:00
ouyangxq 8452f1410f fix(firstleg): hide provider framework errors 2026-09-09 01:20:08 +08:00
ouyangxq 2abbb3cc8f fix(acceptance): preserve calculated dropship fees 2026-09-09 01:09:31 +08:00
ouyangxq 0d73e185c0 fix(acceptance): align latest overseas contracts 2026-09-09 00:53:40 +08:00
ouyangxq 5da3417429 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
2026-09-09 00:41:47 +08:00
ouyangxq e7cbe98766 fix(lastleg): restore pallet validation structure 2026-09-09 00:39:38 +08:00
ouyangxq df0088750b fix(lastleg): restore receiving service structure 2026-09-09 00:37:42 +08:00
ouyangxq 2ba5f0c307 fix(lastleg): harden container plan saga recovery 2026-09-09 00:31:50 +08:00
ouyangxq 990b74f3e8 fix(lastleg): confirm pre-reserved pallets on PDA scan 2026-09-09 00:25:57 +08:00
ouyangxq 7e76fed728 fix(acceptance): complete overseas fulfillment closure 2026-09-09 00:25:34 +08:00
ouyangxq 094d9c1e81 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 00:19:29 +08:00
ouyangxq 7b47de47c6 feat(lastleg): reserve pallets for container plans 2026-09-09 00:15:23 +08:00
ouyangxq cf8053b7ce fix(warehouse-overseas): keep confirmed pallet locks active 2026-09-09 00:11:46 +08:00
ouyangxq 93704a24ce fix(warehouse-overseas): avoid pallet batch gap lock 2026-09-08 23:59:50 +08:00
ouyangxq 5086de23a7 fix(acceptance): close overseas dropship pallet chain 2026-09-08 23:56:54 +08:00
ouyangxq fa9888d0e1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/db/incremental.sql
2026-09-08 23:54:12 +08:00
ouyangxq a88a04fd24 feat(warehouse-overseas): reserve inbound pallet batches 2026-09-08 23:52:36 +08:00
ouyangxq 4c1e9aa714 feat(lastleg): add container plan preview 2026-09-08 23:45:30 +08:00
ouyangxq 0c984e8f5f fix(lastleg): inspect occupied pallets for PDA queries 2026-09-08 23:34:12 +08:00
ouyangxq 3e15ad6b5b fix(scripts): align dropship container acceptance 2026-09-08 23:26:09 +08:00
ouyangxq 2121743753 fix(scripts): scope container workbench queries 2026-09-08 23:16:52 +08:00
ouyangxq 7d64135e79 fix(scripts): read domestic container list payload 2026-09-08 23:15:13 +08:00
ouyangxq 735b5abdcc fix(scripts): read container workbench payload 2026-09-08 23:11:49 +08:00
ouyangxq 00990fedcc fix(scripts): use domestic container detail route 2026-09-08 23:06:07 +08:00
ouyangxq f7f4c80806 fix(firstleg): sync container pickup code schema 2026-09-08 22:51:02 +08:00
ouyangxq 0b9cab7c1a fix(firstleg): align dropship pallet sealing contract 2026-09-08 22:34:58 +08:00
ouyangxq 4bbcda2a75 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
2026-09-08 22:34:48 +08:00
ouyangxq ae44b82a85 fix(lastleg): align overseas schema upgrades 2026-09-08 22:17:36 +08:00
ouyangxq 8e3e94b286 fix(lastleg): sync relabel inventory snapshot schema 2026-09-08 22:12:34 +08:00
ouyangxq a060f8dca8 fix(overseas): support projected authorization headers 2026-09-08 21:59:13 +08:00
ouyangxq 55fda1f616 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:59:03 +08:00
ouyangxq e3a5ea7341 feat(lastleg): add overseas container fee sources 2026-09-08 21:45:57 +08:00
ouyangxq 0c4d7c644e fix(gateway): forward standard internal trust header 2026-09-08 21:38:12 +08:00
ouyangxq e9ad7e333a fix(security): distinguish gateway context failures 2026-09-08 21:29:23 +08:00
ouyangxq 781669dfaf Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:29:21 +08:00
ouyangxq 1cf6722d6e fix(finance): key container finance by booking 2026-09-08 21:27:23 +08:00
ouyangxq 55c36cf290 fix(firstleg): publish fees by container booking 2026-09-08 21:24:49 +08:00
ouyangxq d0c4d5f997 test(security): cover gateway token authentication 2026-09-08 21:22:24 +08:00
ouyangxq 23ac00eb61 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 21:05:33 +08:00
ouyangxq 4a403aaa30 fix(security): accept overseas token subject identity 2026-09-08 21:05:15 +08:00
ouyangxq 1fb139bc71 test(finance): sync formal fee item baseline 2026-09-08 21:05:15 +08:00
ouyangxq 8e0001dc56 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:05:05 +08:00
ouyangxq 2e2ca3bef4 feat(finance): add container settlement aggregate 2026-09-08 21:03:47 +08:00
liuyihe f81fac8f6b Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 20:52:55 +08:00
ouyangxq aa7f067e54 test(firstleg): confirm fee adjustment tolerance 2026-09-08 20:51:08 +08:00
liuyihe c6156771b4 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 20:50:41 +08:00
liuyihe 4d4008cdb3 Merge origin/dev and resolve backend conflicts 2026-09-08 20:50:28 +08:00
ouyangxq 93bf192e3d fix(lastleg): sync delivery lifecycle schema 2026-09-08 20:37:41 +08:00
ouyangxq cafa66fd37 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 20:37:19 +08:00
ouyangxq fc079fcdc0 fix(firstleg): allow domestic history container details 2026-09-08 20:29:05 +08:00
liuyihe 249bf0ab4b 修改提交 2026-09-08 20:25:05 +08:00
ouyangxq da5cf0eb64 fix(deploy): sync inbound fee pricing columns 2026-09-08 20:23:02 +08:00
ouyangxq 11076cef00 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 20:22:49 +08:00
ouyangxq b19b828861 fix(sql): sync phase5 delivery permissions 2026-09-08 20:18:35 +08:00
ouyangxq 2f01fadeeb chore(sql): sync container workspace permissions 2026-09-08 20:05:17 +08:00
ouyangxq d94c902d75 feat(firstleg): split domestic and overseas container workbenches 2026-09-08 19:48:16 +08:00
ouyangxq 19d9a89651 feat(iam): split domestic and overseas container permissions 2026-09-08 19:11:29 +08:00
ouyangxq b0c81e337d feat(firstleg): expose batch carrier actual facts 2026-09-08 19:09:08 +08:00
ouyangxq 75e67fa980 feat(iam): add exception order process permission 2026-09-08 18:51:22 +08:00
ouyangxq d1090671ce fix(lastleg): enforce delivery and return workflows 2026-09-08 18:49:52 +08:00
ouyangxq d42629d289 feat(lastleg): add direct inventory balance detail 2026-09-08 18:47:15 +08:00
ouyangxq 82337da0cc Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:45:13 +08:00
ouyangxq 6514634dcb fix(lastleg): align receiving warehouse checks 2026-09-08 18:41:00 +08:00
ouyangxq 3e7fa40a99 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java
2026-09-08 18:39:34 +08:00
ouyangxq 47419aad00 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
2026-09-08 18:37:23 +08:00
ouyangxq f6b0bca2bf test(firstleg): mock container tier mapper 2026-09-08 18:32:59 +08:00
ouyangxq db98da5689 fix(lastleg): restore returned inventory from shipment source 2026-09-08 18:30:04 +08:00
ouyangxq 047ed674b8 feat(file): support overseas exception evidence 2026-09-08 18:26:26 +08:00
ouyangxq 3b88e5f6ab fix(firstleg): reprice container fee on loading changes 2026-09-08 18:24:04 +08:00
ouyangxq cd47317e8a test(firstleg): provide container pricing mapper fixture 2026-09-08 18:21:46 +08:00
ouyangxq 6ac80bf659 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:21:46 +08:00
ouyangxq 1fd2b544a5 优化 2026-09-08 18:18:51 +08:00
ouyangxq fcb4c055e2 优化 2026-09-08 18:16:54 +08:00
ouyangxq c6657929da Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:16:53 +08:00
ouyangxq 1a1a19dea7 feat(firstleg): tier container customer freight 2026-09-08 18:04:14 +08:00
ouyangxq 3aaba2d6a3 fix: order dropship schema convergence before assertions 2026-09-08 18:02:47 +08:00
ouyangxq 13d8640b4c Merge remote-tracking branch 'origin/test' into dev 2026-09-08 17:59:53 +08:00
liuyihe 2a27e28f26 修改提交 2026-09-08 17:58:42 +08:00
ouyangxq f3a0a45e59 fix(lastleg): stock receipts into scanned locations 2026-09-08 17:55:29 +08:00
ouyangxq b0fd797cd2 feat(firstleg): support customer price books 2026-09-08 17:42:20 +08:00
ouyangxq 751bdbf880 fix: converge dropship development schema 2026-09-08 17:38:43 +08:00
ouyangxq 0a9c798580 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
#	order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
#	order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java
2026-09-08 17:34:12 +08:00
ouyangxq 60a41ec3ea fix: reserve stable inbound code for draft label printing 2026-09-08 17:33:44 +08:00
ouyangxq eead11e99d feat(order): add batch review preview 2026-09-08 17:32:56 +08:00
ouyangxq 442518c382 feat(firstleg): add private warehouse delivery fee 2026-09-08 17:27:20 +08:00
ouyangxq 6c2d169798 feat: complete dropship palletization workflow 2026-09-08 17:24:48 +08:00
ouyangxq ba81dece5e feat(firstleg): expose authoritative measurement identifiers 2026-09-08 17:24:29 +08:00
ouyangxq e597bebafd feat(order): expose cargo shipment binding 2026-09-08 17:17:57 +08:00
ouyangxq 2e60f2cd77 feat(firstleg): enforce pricing adjustment bounds 2026-09-08 17:13:43 +08:00
ouyangxq 78a73a1b0e feat(firstleg): expose loading item tax mode 2026-09-08 17:07:06 +08:00
ouyangxq e9182e8cfe Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-08 17:01:59 +08:00
ouyangxq 40fe0ba0e1 fix(lastleg): keep inventory warehouse snapshots canonical 2026-09-08 16:16:20 +08:00
ouyangxq 6908969307 fix(lastleg): reject unavailable receiving pallets 2026-09-08 14:52:08 +08:00
ouyangxq a0ad3382af fix(warehouse-overseas): enforce pallet location invariant 2026-09-08 14:47:35 +08:00
ouyangxq 07688005a1 优化 2026-09-08 14:07:31 +08:00
ouyangxq d318f4a450 fix(lastleg): create remaining container plans 2026-09-08 13:52:37 +08:00
ouyangxq 3c05525e27 fix(lastleg): search frozen warehouse code and name 2026-09-08 13:14:13 +08:00
ouyangxq 096ab8d48b fix(test): align receiving pallets with FBA destinations 2026-09-08 12:45:46 +08:00
ouyangxq 847df6a85f fix(lastleg): keep completed containers editable 2026-09-08 11:51:04 +08:00
ouyangxq cc6df5993f fix(lastleg): enforce container warehouse plan facts 2026-09-08 11:00:39 +08:00
ouyangxq 61ffff1be0 fix: keep terminal drayage actions executable 2026-09-08 07:39:17 +08:00
ouyangxq 287c0f3ce8 fix: publish holds for local overseas orders 2026-09-08 00:51:32 +08:00
ouyangxq bb68e8a580 fix: route terminal drayage through gateway 2026-09-08 00:23:40 +08:00
ouyangxq eeb087e453 fix: make terminal drayage migration restartable 2026-09-08 00:11:31 +08:00
ouyangxq e32fe65fa8 feat: complete tail order controls and terminal drayage 2026-09-07 23:58:18 +08:00
ouyangxq f018ca85a9 test(overseas): retain marker merchant fixture 2026-09-07 21:26:03 +08:00
ouyangxq ac18f69ec5 fix(lastleg): preserve dropship source pallets 2026-09-07 21:10:43 +08:00
ouyangxq 98dafc13ca fix(lastleg): align outbound pallet workflows 2026-09-07 20:51:03 +08:00
ouyangxq 4895c4f738 优化 2026-09-07 19:59:31 +08:00
ouyangxq df8610f458 fix(lastleg): refresh received pallets before delivery transfer 2026-09-07 19:16:30 +08:00
ouyangxq e0bb7de0cf fix: isolate invalid container progress snapshots 2026-09-07 16:36:21 +08:00
ouyangxq d622ddd37f fix: isolate invalid receiving candidates 2026-09-07 16:06:08 +08:00
ouyangxq ff2b02b102 fix: finalize smart dispatch drafts 2026-09-07 15:54:10 +08:00
ouyangxq f0939c91c3 优化 2026-09-07 15:34:36 +08:00
ouyangxq 1b47158770 优化 2026-09-07 15:07:34 +08:00
ouyangxq 693e10188c fix: refresh overseas authorization on user status change 2026-09-07 14:22:26 +08:00
ouyangxq 2c5fda33e7 fix: complete overseas fulfillment acceptance 2026-09-07 14:03:00 +08:00
ouyangxq fcf1771967 fix: restore overseas list queries and identifiers 2026-09-07 12:30:17 +08:00
ouyangxq 30e883fe0c test: complete held binding fixture 2026-09-07 12:17:56 +08:00
ouyangxq 51e5c6dc64 Merge remote-tracking branch 'origin/test' into dev 2026-09-07 12:17:01 +08:00
ouyangxq e281a8f20b fix: preserve hold binding replays 2026-09-07 12:16:55 +08:00
ouyangxq 0aecc90d11 test: cover concurrent inbound hold 2026-09-07 12:16:19 +08:00
ouyangxq a077f828ae feat: return authorization snapshot outside jwt 2026-09-07 12:14:48 +08:00
ouyangxq c6b598daa7 Merge remote-tracking branch 'origin/test' into dev 2026-09-07 12:10:19 +08:00
ouyangxq 41582fe17b fix: close inbound hold bypass paths 2026-09-07 12:10:11 +08:00
ouyangxq a99ca36316 fix: align overseas outbound schema entry 2026-09-07 11:48:49 +08:00
ouyangxq 4963889024 fix: project overseas authorization outside jwt 2026-09-07 11:37:06 +08:00
ouyangxq 6b479d1a68 test: expand inbound hold acceptance 2026-09-07 11:22:57 +08:00
ouyangxq 0c8f6fe2b3 feat: add inbound hold lifecycle 2026-09-07 11:20:10 +08:00
ouyangxq e52ecb9315 merge: sync test into dev before hold flow 2026-09-07 10:37:14 +08:00
liuyihe 9d213b3843 修改提交 2026-09-06 16:02:11 +08:00
liuyihe 3ae8964fe5 修改提交 2026-09-06 15:02:33 +08:00
ouyangxq 7fdc58380f test: include relabel charge amount 2026-09-06 12:24:42 +08:00
ouyangxq 1a7d4bb39f fix: order wallet database assertions 2026-09-06 12:02:11 +08:00
ouyangxq 42ddc19826 test: deduplicate acceptance coverage gaps 2026-09-06 11:41:46 +08:00
ouyangxq 54af43a99d test: align wallet ledger assertions with contract 2026-09-06 11:15:48 +08:00
ouyangxq 0fbab09890 test: preserve uploaded file ids in wallet flow 2026-09-06 11:09:53 +08:00
ouyangxq 20b3074cb1 test: automate overseas wallet and pallet cost closure 2026-09-06 11:00:06 +08:00
ouyangxq 64bf5aa0fe fix: accept wallet result event hashes 2026-09-05 23:32:31 +08:00
ouyangxq 81917423eb fix: relay wallet result events 2026-09-05 23:24:34 +08:00
ouyangxq af7852416a fix: accept prefixed wallet event hashes 2026-09-05 23:13:24 +08:00
ouyangxq 3c535731f7 fix: configure finance wallet event delivery 2026-09-05 22:59:08 +08:00
ouyangxq 718a54ec9e fix: bound wallet event metric labels 2026-09-05 22:34:56 +08:00
ouyangxq 5fbd2bbd04 feat: close overseas wallet and pallet cost flow 2026-09-05 22:26:25 +08:00
ouyangxq f62283fb7f test(finance): cover internal wallet token boundary 2026-09-05 16:46:40 +08:00
ouyangxq 8400636df4 test(finance): expand wallet credit boundary coverage 2026-09-05 16:43:33 +08:00
ouyangxq ede63fac24 fix(finance): align permission migration totals 2026-09-05 12:16:57 +08:00
ouyangxq ba4407eb0d feat(finance): add credit and wallet hold lifecycle 2026-09-05 11:54:55 +08:00
ouyangxq 6f2b2babc6 fix(web): map missing multipart parts to bad request 2026-09-05 10:44:13 +08:00
ouyangxq 37183ac2f0 fix(deploy): identify development wallet runtime 2026-09-05 09:01:43 +08:00
ouyangxq 3df6fb511f fix(finance): expose sample recharge QR in dev 2026-09-05 08:56:02 +08:00
ouyangxq cf955477d8 fix(finance): enable development recharge channels 2026-09-05 08:50:39 +08:00
ouyangxq b4fff67303 chore(finance): enable development recharge sample 2026-09-05 08:41:24 +08:00
ouyangxq ad101ea42e fix(sql): preserve quoted audit comments 2026-09-05 08:39:24 +08:00
ouyangxq 5792630231 feat(finance): add customer wallet recharge flow 2026-09-05 08:34:40 +08:00
ouyangxq 6002e4223b test: align smart loading mapper signature 2026-09-04 22:18:19 +08:00
ouyangxq 7bfccc87fc test: guard unified sql comment ordering 2026-09-04 18:54:12 +08:00
ouyangxq 77817b4c94 优化 2026-09-04 18:47:57 +08:00
ouyangxq 7c5ed85a36 test: select live unpaid settlement fixture 2026-09-04 18:16:27 +08:00
ouyangxq 991bab9cb1 test: stabilize full development acceptance 2026-09-04 17:17:08 +08:00
ouyangxq 61b7b3fb02 test: align task registry index contract 2026-09-04 16:43:51 +08:00
ouyangxq 07bcf316e8 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/acceptance-finance-full-contract.test.mjs
#	scripts/admin-test-env-acceptance.mjs
2026-09-04 16:43:43 +08:00
ouyangxq 1e980638c4 优化 2026-09-04 16:23:38 +08:00
ouyangxq 23bb915d92 test: preserve direct paid finance ordering 2026-09-04 16:20:09 +08:00
ouyangxq d284fc0637 优化 2026-09-04 16:06:47 +08:00
ouyangxq b8a649e705 test: retry transient unauthenticated upload probe 2026-09-04 16:04:12 +08:00
ouyangxq e477f73983 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	scripts/AGENTS.md
#	scripts/admin-test-env-modules/README.md
#	scripts/agents-index-contract.ps1
2026-09-04 16:03:09 +08:00
ouyangxq 56a6e690ed 优化 2026-09-04 16:02:05 +08:00
ouyangxq 1f1a7825e3 优化 2026-09-04 16:02:03 +08:00
ouyangxq c6282e7e2f test: align smart loading limit error code 2026-09-04 15:56:17 +08:00
ouyangxq 6cd09ed2fa 优化 2026-09-04 15:55:43 +08:00
ouyangxq dc6a0debca fix: deduplicate unified order locations 2026-09-04 15:54:53 +08:00
ouyangxq c3f45c6bff fix: refresh dimension config response 2026-09-04 15:44:00 +08:00
ouyangxq eba86bb666 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs
2026-09-04 15:41:18 +08:00
ouyangxq 15b16634db test: cover smart loading quantity operations 2026-09-04 15:36:56 +08:00
ouyangxq 9c96bdfaa8 test: require incremental pallet growth 2026-09-04 15:30:59 +08:00
ouyangxq 7babcb9e0d test: expand truck attachment acceptance contract 2026-09-04 15:29:43 +08:00
ouyangxq c8c402ffc5 test: detect shared overflow evidence 2026-09-04 15:29:06 +08:00
ouyangxq c55f2aed11 test: add pallet location http matrix 2026-09-04 15:24:45 +08:00
ouyangxq 4aed53a8af 优化 2026-09-04 15:19:45 +08:00
ouyangxq 1c8d058d71 test: avoid duplicate overseas PDA lock assertion 2026-09-04 15:18:57 +08:00
ouyangxq 21796cb83d Merge remote-tracking branch 'origin/test' into dev 2026-09-04 15:16:16 +08:00
ouyangxq 2c21d27f61 fix: keep location guidance non-blocking 2026-09-04 15:15:12 +08:00
ouyangxq e23c5dcb7b 优化 2026-09-04 15:06:36 +08:00
ouyangxq 8a0366af5b fix: reserve locations from measured volume 2026-09-04 15:05:44 +08:00
ouyangxq f94fbaa9b4 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-04 14:50:25 +08:00
ouyangxq 5e013489e0 优化 2026-09-04 14:41:23 +08:00
ouyangxq 760c7b561d fix: stabilize smart loading plan reads 2026-09-04 14:28:25 +08:00
ouyangxq 2c94e212ca 优化 2026-09-04 14:17:34 +08:00
ouyangxq 053c538f94 fix: preserve pallet snapshots across partial pick 2026-09-04 14:09:41 +08:00
ouyangxq 3399ae7488 fix: return A0404 for missing PDA pallets 2026-09-04 14:07:18 +08:00
ouyangxq f6247ce232 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 14:06:02 +08:00
ouyangxq b79d136925 优化 2026-09-04 14:04:52 +08:00
ouyangxq f70d38a034 优化 2026-09-04 14:04:23 +08:00
ouyangxq b428ac1c6c Merge remote-tracking branch 'origin/test' into dev 2026-09-04 14:04:07 +08:00
ouyangxq 93f06f8ff8 fix: fall back to order declared volume 2026-09-04 14:03:04 +08:00
ouyangxq a08559e163 test: use original token for dynamic stale snapshot 2026-09-04 13:50:11 +08:00
ouyangxq 7feb80689b test: verify pallet block settlement over HTTP 2026-09-04 13:48:03 +08:00
ouyangxq 7d9b2749d5 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 13:47:47 +08:00
ouyangxq 660e284e25 fix: reserve pallet block from declared volume 2026-09-04 13:28:57 +08:00
ouyangxq 944ecb2bf3 fix: allocate pallet locations on weight confirmation 2026-09-04 13:21:37 +08:00
ouyangxq 81470359f2 fix: include customer cross-border authorities overseas 2026-09-04 13:15:28 +08:00
ouyangxq 54cb9ceade feat: complete overseas customer and PDA workflows 2026-09-04 12:59:37 +08:00
ouyangxq a274d029bb Merge remote-tracking branch 'origin/test' into dev 2026-09-04 12:55:52 +08:00
ouyangxq d6956d396c fix: remove legacy PDA release columns 2026-09-04 12:51:17 +08:00
ouyangxq 58bf9a3503 docs: register pallet guide acceptance 2026-09-04 12:49:15 +08:00
ouyangxq f556d3ca7e test: cover pallet location guide over HTTP 2026-09-04 12:48:55 +08:00
ouyangxq db6ed676ac test: align pallet block contracts 2026-09-04 12:35:55 +08:00
ouyangxq 209255f17e 优化 2026-09-04 12:31:52 +08:00
ouyangxq 9bf0b4d331 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	order/AGENTS.md
2026-09-04 12:29:57 +08:00
ouyangxq 1407f616a1 Merge remote-tracking branch 'origin/test' into test 2026-09-04 12:29:25 +08:00
ouyangxq 8099343fca fix: align PDA inventory with pallet location blocks 2026-09-04 12:28:47 +08:00
liuyihe 5c094fd0fd 修改提交 2026-09-04 12:23:26 +08:00
ouyangxq b697e4c9f0 优化 2026-09-04 12:18:36 +08:00
ouyangxq 566b07be62 Merge remote-tracking branch 'origin/test' into test 2026-09-04 12:03:17 +08:00
ouyangxq b971ca2f7c test: expand domestic container acceptance coverage 2026-09-04 12:02:38 +08:00
liuyihe 6f32971d79 修改提交 2026-09-04 11:50:01 +08:00
ouyangxq 55cad93be3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-04 11:35:21 +08:00
ouyangxq f23f3ea26d 优化 2026-09-04 11:35:19 +08:00
liuyihe 451c791f10 修改提交 2026-09-04 11:34:25 +08:00
ouyangxq c3729249c4 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-04 10:51:26 +08:00
ouyangxq 7a0e158b51 优化 2026-09-04 10:51:25 +08:00
ouyangxq da23683440 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 10:37:19 +08:00
ouyangxq a0ba933db3 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
#	scripts/admin-test-env-modules/inbound-measurement.mjs
2026-09-04 10:37:12 +08:00
liuyihe e6859c470c 修改提交 2026-09-04 10:34:35 +08:00
liuyihe 4ca5a57e6a 修改提交 2026-09-04 10:13:46 +08:00
ouyangxq edadca18c6 perf(firstleg): streamline smart loading assignments 2026-09-04 09:57:39 +08:00
ouyangxq b5be60140e test(firstleg): align full acceptance contracts 2026-09-04 00:41:34 +08:00
ouyangxq ce3cd7e80f fix(firstleg): preserve coload groups in filtered candidates 2026-09-04 00:33:07 +08:00
ouyangxq 65b29adb01 修复智能装柜日期筛选调用 2026-09-03 22:05:27 +08:00
ouyangxq d3eaa901f5 优化 2026-09-03 21:50:21 +08:00
ouyangxq b8826c3e92 优化 2026-09-03 21:48:46 +08:00
ouyangxq eca2c94aa3 优化 2026-09-03 21:48:40 +08:00
ouyangxq 2b461cc6e8 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 21:34:28 +08:00
ouyangxq a5aecf6171 优化 2026-09-03 21:34:27 +08:00
liuyihe da4cb16d52 Merge remote-tracking branch 'origin/test' into test 2026-09-03 21:33:33 +08:00
liuyihe bfb139c7c7 修改提交 2026-09-03 21:33:23 +08:00
ouyangxq effab80db9 Merge branch 'test' into dev 2026-09-03 21:30:08 +08:00
ouyangxq 26c2d7aacd 修复设备测量箱规计算编译错误 2026-09-03 21:29:51 +08:00
ouyangxq 2baf1b1460 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 21:25:54 +08:00
ouyangxq dd1682a83b 优化 2026-09-03 21:25:53 +08:00
ouyangxq 1a3f94dfcb Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
2026-09-03 21:23:35 +08:00
liuyihe baabfdabba 修改提交 2026-09-03 21:09:22 +08:00
ouyangxq 0ad8bb66cc test: bound reused price fixture names 2026-09-03 21:00:11 +08:00
ouyangxq 47a043324f Merge remote-tracking branch 'origin/test' into dev 2026-09-03 20:59:58 +08:00
ouyangxq 171f450e59 优化 2026-09-03 20:38:47 +08:00
ouyangxq a24d55f526 优化 2026-09-03 20:24:52 +08:00
ouyangxq 8b2c2b32b2 test: align tally permission total 2026-09-03 19:41:25 +08:00
ouyangxq 2063d29d16 fix: complete development acceptance paths 2026-09-03 19:10:18 +08:00
ouyangxq 4df0ae4811 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 19:09:04 +08:00
ouyangxq acdc0943c0 feat(order): derive batch customer context 2026-09-03 18:22:44 +08:00
ouyangxq 514a585de2 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	firstleg/AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-03 18:22:05 +08:00
ouyangxq ca288dec72 优化 2026-09-03 18:13:00 +08:00
ouyangxq 66ad9f5c85 优化 2026-09-03 18:05:34 +08:00
ouyangxq 61adfc7818 feat(order): refine batch import workbook 2026-09-03 18:03:42 +08:00
ouyangxq 880f923462 Merge remote-tracking branch 'origin/dev' into dev 2026-09-03 17:52:38 +08:00
ouyangxq 03591d6fbd test(firstleg): 覆盖超限设备测量实际尺寸 2026-09-03 17:52:31 +08:00
ouyangxq 1d8432b8ef Merge remote-tracking branch 'origin/test' into dev 2026-09-03 17:48:01 +08:00
ouyangxq d7132536a8 Merge branch 'test' into dev 2026-09-03 17:44:23 +08:00
ouyangxq 8911d4a242 固定 Jenkins Job 部署环境 2026-09-03 17:43:52 +08:00
ouyangxq 6130a13b6b 修复超限单箱测量尺寸计算 2026-09-03 17:17:57 +08:00
ouyangxq b8bdff2f1f fix(lastleg): preserve readable exception operators 2026-09-03 17:17:10 +08:00
ouyangxq 997419ea6b Merge remote-tracking branch 'origin/test' into dev 2026-09-03 17:14:16 +08:00
ouyangxq 253ca82b99 fix(order): load all warehouse template options 2026-09-03 17:04:57 +08:00
ouyangxq 3664f036cc feat(order): localize batch import workbook 2026-09-03 16:33:33 +08:00
ouyangxq 5c524fb92c test: stabilize phase5 acceptance inventory fixture 2026-09-03 16:20:17 +08:00
ouyangxq a66ecfab98 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-09-03 16:19:48 +08:00
ouyangxq dac02e5253 优化 2026-09-03 16:09:22 +08:00
ouyangxq 0307acf859 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 15:35:28 +08:00
ouyangxq 36d35276b7 优化 2026-09-03 15:35:26 +08:00
ouyangxq 904b495613 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/maven/settings-cn.xml
2026-09-03 15:23:55 +08:00
liuyihe 29ec202563 修改提交 2026-09-03 15:11:40 +08:00
liuyihe e29695eb3f 修改提交 2026-09-03 15:10:35 +08:00
ouyangxq 6eff5a4b9b 优化 2026-09-03 15:00:55 +08:00
ouyangxq 55592a1f08 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 13:49:41 +08:00
ouyangxq a927903a66 优化 2026-09-03 13:49:38 +08:00
ouyangxq 2041819df5 优化 2026-09-03 13:35:17 +08:00
ouyangxq 732fc66670 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 12:01:00 +08:00
ouyangxq 0d52ee5948 修复订单申报更新测试依赖装配 2026-09-03 11:59:25 +08:00
liuyihe fb91168614 Merge remote-tracking branch 'origin/test' into test 2026-09-03 11:59:15 +08:00
liuyihe f561e6bb09 修改提交 2026-09-03 11:59:06 +08:00
ouyangxq 8de52f65bd Merge remote-tracking branch 'origin/test' into dev 2026-09-03 11:55:55 +08:00
ouyangxq bdcc32a095 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-09-03 11:55:46 +08:00
ouyangxq 59b42c7579 优化 2026-09-03 11:54:17 +08:00
ouyangxq fd3dbb2fc6 优化 2026-09-03 11:47:28 +08:00
ouyangxq b15d003217 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 11:34:52 +08:00
ouyangxq 757f6ef24f 修复头程测量与计费测试门禁 2026-09-03 11:32:08 +08:00
ouyangxq d56af7e036 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
2026-09-03 11:03:25 +08:00
liuyihe a3715f618f 一件代发拣货 2026-09-03 10:44:34 +08:00
ouyangxq cf77e19765 feat(file): expedite pending export retries 2026-09-03 10:40:21 +08:00
ouyangxq 6b2339e50a feat(file): allow immediate retry during export backoff 2026-09-03 10:39:55 +08:00
ouyangxq 16a60a9175 fix(file): align order export batch limit 2026-09-03 10:23:01 +08:00
ouyangxq 12b749d7d8 fix(iam): grant domestic order export permission 2026-09-03 10:12:08 +08:00
ouyangxq 7359fbc093 fix: hide completed receiving containers 2026-09-03 10:08:57 +08:00
ouyangxq 59add49408 fix: synchronize order declaration schema 2026-09-02 20:49:26 +08:00
ouyangxq 11d89435f5 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-acceptance.mjs
#	scripts/agents-index-contract.ps1
#	scripts/full-regression-entry-contract.test.mjs
2026-09-02 20:32:23 +08:00
ouyangxq 6f5398770b feat(finance): expose inbound code on bill lines 2026-09-02 20:25:54 +08:00
ouyangxq e4ee999098 优化 2026-09-02 20:14:47 +08:00
ouyangxq fa9a98f078 优化 2026-09-02 19:53:07 +08:00
ouyangxq 71f6dfa1eb test: extend finance and cross-region acceptance 2026-09-02 19:29:23 +08:00
ouyangxq 4521090967 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 18:40:20 +08:00
ouyangxq 9adbdfbcc1 统一财务事件投递端口 2026-09-02 18:28:39 +08:00
ouyangxq 4e2703295c fix: use published fees for client phase2 acceptance 2026-09-02 18:25:42 +08:00
ouyangxq b998a0c073 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 18:25:19 +08:00
ouyangxq 30090e741f 修复财务事件桥投递地址 2026-09-02 18:24:39 +08:00
ouyangxq eef45eda65 fix: synchronize unified firstleg migration 2026-09-02 18:17:00 +08:00
ouyangxq 8246093af1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	auth/AGENTS.md
#	auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java
#	auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java
2026-09-02 18:14:50 +08:00
ouyangxq 596a5c925c 支持柜费按订单归属分摊 2026-09-02 18:14:25 +08:00
ouyangxq 62855915fc 补齐海外财务权限令牌 2026-09-02 18:09:43 +08:00
ouyangxq 6be5e0d81f 优化 2026-09-02 18:03:36 +08:00
ouyangxq 6296f032f0 fix: retain scanned carton traceability in measurement drafts 2026-09-02 17:57:35 +08:00
ouyangxq 2a0f942234 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java
#	firstleg/src/main/resources/db/phase3-container-customs-schema.sql
#	order/src/main/resources/db/order.sql
#	scripts/db/incremental.sql
2026-09-02 17:56:20 +08:00
ouyangxq 18c673811f 补齐财务事件监控标签 2026-09-02 17:45:17 +08:00
ouyangxq 5099a46581 补齐统一数据库中文注释 2026-09-02 17:36:37 +08:00
ouyangxq 3bdaba2c60 完善财务费用来源与柜级分摊 2026-09-02 17:07:03 +08:00
ouyangxq c5c80d5ab6 fix(lastleg): align deployment schema contracts 2026-09-02 16:55:38 +08:00
ouyangxq e06c12c263 fix(lastleg): resolve exceptions by inbound relation 2026-09-02 16:48:45 +08:00
ouyangxq 6cd477a037 修正四期全量验收夹具 2026-09-02 16:48:05 +08:00
ouyangxq a8c71fef12 fix(acceptance): select booking agents by carrier 2026-09-02 16:38:44 +08:00
ouyangxq 1b1f2718b3 fix(firstleg): sync finance allocation schema 2026-09-02 16:24:47 +08:00
ouyangxq 42021b351e Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java
2026-09-02 16:19:06 +08:00
ouyangxq 03d04431f2 优化 2026-09-02 16:17:41 +08:00
ouyangxq 12fbf2aa67 隔离头程定时任务执行线程 2026-09-02 16:10:52 +08:00
ouyangxq 7eabbdcd45 fix(firstleg): include declaration sync schema 2026-09-02 16:07:32 +08:00
ouyangxq 57d0073b40 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-02 16:04:08 +08:00
ouyangxq 601ae83504 收敛海外PDA回归夹具选择 2026-09-02 16:01:58 +08:00
ouyangxq cc04a5570c fix(acceptance): update declaration before fulfillment convergence 2026-09-02 15:59:13 +08:00
ouyangxq 337033b350 fix(acceptance): read nested order declaration details 2026-09-02 15:53:11 +08:00
ouyangxq afdee17ec0 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 15:53:11 +08:00
ouyangxq 5f688d6131 修正海外PDA实装箱唛验收夹具 2026-09-02 15:47:13 +08:00
ouyangxq feda5cd061 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java
2026-09-02 15:46:10 +08:00
ouyangxq ef74579f2c test(overseas): align phase four to five acceptance contracts 2026-09-02 15:43:11 +08:00
ouyangxq 137e4e7c60 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-02 15:43:00 +08:00
ouyangxq ecf4eb1211 优化 2026-09-02 15:42:22 +08:00
ouyangxq 2902ab6077 fix(deploy): prioritize BaoTa-visible container logs 2026-09-02 15:39:58 +08:00
ouyangxq 25c4dd50b3 fix(deploy): bound infrastructure container logs 2026-09-02 15:30:29 +08:00
ouyangxq 9e0d2f6cdb 修正海外PDA候选验收门禁判断 2026-09-02 15:25:30 +08:00
ouyangxq e946d99ea3 docs: clarify inbound plan pending filter 2026-09-02 15:24:56 +08:00
ouyangxq 0c36ea0e12 docs: align repository guidance with test contracts 2026-09-02 15:22:16 +08:00
ouyangxq eab8a1ae5f Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
#	scripts/agents-index-contract.ps1
2026-09-02 15:21:20 +08:00
ouyangxq 70d66c2fae 优化 2026-09-02 15:15:41 +08:00
ouyangxq b65f98451d 同步海外PDA柜级转换验收契约 2026-09-02 15:15:07 +08:00
ouyangxq e48e8dca42 fix(deploy): eliminate duplicate container log writes 2026-09-02 15:14:24 +08:00
ouyangxq 8da57487c0 修复一件代发多票事件幂等冲突 2026-09-02 15:04:15 +08:00
ouyangxq 3d81b22850 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
2026-09-02 14:46:15 +08:00
ouyangxq 19a6af1c6d 优化 2026-09-02 14:45:34 +08:00
ouyangxq 9ea4ccb9b1 优化 2026-09-02 14:39:28 +08:00
ouyangxq b6800a48c6 test(acceptance): follow automatic departure flow 2026-09-02 14:16:44 +08:00
ouyangxq afb38f3c3f fix(dispatch): expose PDA departure gate 2026-09-02 14:00:54 +08:00
ouyangxq 25ffd62e14 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java
#	firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java
#	scripts/admin-test-env-modules/ocean-bookings.mjs
#	scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-customs.mjs
#	scripts/admin-test-env-modules/phase3-loading-finalize.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
#	scripts/admin-test-env-modules/phase3-port-schedules.mjs
#	scripts/admin-test-env-modules/service-center-flow.mjs
2026-09-02 14:00:44 +08:00
ouyangxq 5d098c9762 test(acceptance): align retained state fixtures 2026-09-02 13:59:05 +08:00
ouyangxq f4c139815c 优化 2026-09-02 13:38:44 +08:00
ouyangxq 69ab3e9a90 优化 2026-09-02 13:34:53 +08:00
ouyangxq 68340eb616 test(acceptance): align current gateway contracts 2026-09-02 13:26:24 +08:00
ouyangxq f7f8ff53c0 test(acceptance): align automatic preload workflows 2026-09-02 13:03:03 +08:00
ouyangxq 275d4df35d fix(firstleg): make loading evidence upload replayable 2026-09-02 12:53:18 +08:00
ouyangxq e3fd48c3b9 fix(firstleg): restore smart loading candidate projection 2026-09-02 12:33:26 +08:00
ouyangxq a4d8fa9f8b fix(order): preserve submitted review status 2026-09-02 12:19:46 +08:00
ouyangxq 1574263836 fix(lastleg): validate dropship SKU for PDA outbound 2026-09-02 12:09:14 +08:00
ouyangxq 08be508501 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	order/AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-02 12:08:08 +08:00
ouyangxq 9e70f4339a fix(file): allow standalone legal-person ID upload 2026-09-02 12:07:32 +08:00
ouyangxq f0da6e5110 优化 2026-09-02 11:59:54 +08:00
ouyangxq d786b88030 fix(firstleg): map declaration inbound projection 2026-09-02 11:51:56 +08:00
ouyangxq 6acb5297e5 test(firstleg): lock internal declaration endpoints 2026-09-02 10:45:09 +08:00
ouyangxq bb9fb9ffe6 fix(firstleg): retain declaration event idempotency history 2026-09-02 10:44:17 +08:00
ouyangxq 8e7835fe86 优化 2026-09-02 10:41:32 +08:00
ouyangxq d0d0b2cdda Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-02 10:34:02 +08:00
ouyangxq 31996491be test(firstleg): bind channel change hash to full payload 2026-09-02 10:29:24 +08:00
ouyangxq e0a1f15777 优化 2026-09-02 10:27:47 +08:00
ouyangxq 371aa3dbcb 优化 2026-09-02 10:26:40 +08:00
ouyangxq 5f8b7baa06 优化 2026-09-02 10:26:37 +08:00
ouyangxq 37c37c75c8 优化 2026-09-02 10:26:34 +08:00
ouyangxq 70bbdd5b7b fix(auth): decouple managed login phones from company contacts 2026-09-02 10:11:11 +08:00
ouyangxq da56f261cd 优化 2026-09-02 10:05:56 +08:00
ouyangxq 8a66af81b1 优化 2026-09-02 10:04:54 +08:00
ouyangxq e83dff6ac7 优化 2026-09-02 10:04:18 +08:00
ouyangxq 79c390ad58 test: close phase five inventory snapshot coverage 2026-09-02 03:51:07 +08:00
ouyangxq a4a4306154 test: align phase five dev acceptance flows 2026-09-02 03:32:35 +08:00
ouyangxq f3be06a3e0 test: cover merchant phase 5 delivery 2026-09-02 02:51:58 +08:00
ouyangxq b5d7be7d28 fix: replay completed dispatch picks 2026-09-02 02:03:15 +08:00
ouyangxq 666add1cf9 fix: complete overseas PDA inventory ledgers 2026-09-02 01:47:00 +08:00
ouyangxq cc02037f78 fix: preserve dropship pallets in container plans 2026-09-02 01:17:00 +08:00
ouyangxq fd9a070efc fix: settle whole dropship pallets by sku 2026-09-02 00:27:04 +08:00
ouyangxq 7e6c7a4b9d test: preserve whole dropship pallets in producer flow 2026-09-02 00:16:53 +08:00
ouyangxq cd749733de fix: preserve dropship sku loading details 2026-09-02 00:01:39 +08:00
ouyangxq 571a5544a3 test: pick complete dropship pallet fixtures 2026-09-01 23:47:18 +08:00
ouyangxq fa7a7b8630 fix: enforce pickup-ready receiving candidates 2026-09-01 23:35:23 +08:00
ouyangxq f4a3b58959 fix: validate overseas handover before settlement 2026-09-01 23:20:34 +08:00
ouyangxq e19224c4b7 fix: make loading evidence replay idempotent 2026-09-01 22:59:50 +08:00
ouyangxq d965a82cfd test: accept automatic outbound confirmation 2026-09-01 22:54:02 +08:00
ouyangxq 7f9649f309 test: align outbound producer with container customs 2026-09-01 22:52:39 +08:00
ouyangxq 20eb524a51 test: complete dropship palletization before smart loading 2026-09-01 22:38:16 +08:00
ouyangxq bc596878d6 fix: keep order declaration schema in unified baseline 2026-09-01 22:30:15 +08:00
ouyangxq aa77cece23 fix: align smart loading candidate mapping 2026-09-01 22:09:01 +08:00
ouyangxq ad5e0812bf fix: restore phase five development acceptance chain 2026-09-01 21:55:27 +08:00
ouyangxq 04e0111756 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 21:51:52 +08:00
ouyangxq b85e021cbe test(order): cover declaration field clearing 2026-09-01 21:11:37 +08:00
ouyangxq e82cefe493 优化 2026-09-01 21:09:33 +08:00
ouyangxq 47723c7c0f feat(file): register domestic order exports 2026-09-01 21:03:57 +08:00
ouyangxq b9cb5555e2 fix(firstleg): isolate declarations from measurement facts 2026-09-01 20:59:30 +08:00
ouyangxq 858061452c feat(order): formalize domestic order export contract 2026-09-01 20:48:44 +08:00
ouyangxq 83f4e1a3de test: align loading tests with latest test contract 2026-09-01 20:47:27 +08:00
ouyangxq b2ec00754a Merge remote-tracking branch 'origin/test' into dev 2026-09-01 20:46:48 +08:00
ouyangxq 94c67b5c51 优化 2026-09-01 20:41:22 +08:00
ouyangxq 6449daf823 fix: allow optional phase5 custom admin role 2026-09-01 20:36:24 +08:00
ouyangxq 974d7b1e43 fix: complete phase5 overseas deployment contract 2026-09-01 20:20:25 +08:00
ouyangxq 8e17401f37 优化 2026-09-01 20:07:38 +08:00
ouyangxq 539030d2b6 fix(file): return controlled missing package errors 2026-09-01 20:01:14 +08:00
ouyangxq d88e5cffce fix: align phase5 overseas permissions 2026-09-01 20:00:49 +08:00
ouyangxq a5d787b279 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:59:24 +08:00
ouyangxq b4e496e9cb fix(file): support optional legal ID and order import files 2026-09-01 19:52:42 +08:00
ouyangxq 9fc20d5482 fix: keep ambiguous legacy phones unbound 2026-09-01 19:34:17 +08:00
ouyangxq 271a77fd86 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:32:20 +08:00
ouyangxq 2ec8fcb886 fix(order): route customer addresses through gateway 2026-09-01 19:32:01 +08:00
ouyangxq 405d6b94d7 fix(auth): reject placeholder mobile numbers 2026-09-01 19:27:46 +08:00
ouyangxq 176d146b8e fix: preserve merchant warehouse authorities overseas 2026-09-01 19:25:48 +08:00
ouyangxq 28526bf43e Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:25:16 +08:00
ouyangxq caffcb90a9 feat(order): add customer order foundations 2026-09-01 19:17:43 +08:00
ouyangxq 222c3790dc feat(auth): support username or phone login 2026-09-01 19:15:32 +08:00
ouyangxq b6e81bab6b test: align inbound status contract with dropship palletizing 2026-09-01 19:11:36 +08:00
ouyangxq 1c26f7db9b Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:00:36 +08:00
ouyangxq 0ba3df2fce test(firstleg): cover booking-level truck lifecycle 2026-09-01 16:36:41 +08:00
ouyangxq 9098d8dfdf test(firstleg): decouple PDA completion from truck 2026-09-01 16:33:57 +08:00
ouyangxq 20d0d84240 fix: preserve lastleg comments in overseas migration 2026-09-01 16:30:41 +08:00
ouyangxq eb385fe906 test(firstleg): align outbound truck lookup contract 2026-09-01 16:24:10 +08:00
ouyangxq ecc83ed64a fix(firstleg): scope truck appointments by booking 2026-09-01 16:21:27 +08:00
ouyangxq c319986738 fix: order lastleg delivery baseline for clean database 2026-09-01 16:18:38 +08:00
ouyangxq a8f8ff2b6d Merge remote-tracking branch 'origin/test' into dev 2026-09-01 15:59:15 +08:00
ouyangxq ca8127ce83 test(firstleg): formalize late container gate acceptance 2026-09-01 15:53:12 +08:00
ouyangxq e02c463be7 优化 2026-09-01 15:46:30 +08:00
ouyangxq aa828ca1eb 优化 2026-09-01 15:43:19 +08:00
ouyangxq df1ffbc6bc fix(firstleg): filter truck booking candidates 2026-09-01 15:41:36 +08:00
ouyangxq 8b2a964bfb fix(firstleg): allow late initial container fill 2026-09-01 15:38:33 +08:00
ouyangxq 07ffd8ddce fix(firstleg): finalize late container gate 2026-09-01 15:27:34 +08:00
ouyangxq d1225845ec 优化 2026-09-01 15:25:15 +08:00
ouyangxq 4d7ea28a61 feat(firstleg): capture container number at pickup 2026-09-01 15:18:18 +08:00
liuyihe 8a94c541cb Merge remote-tracking branch 'origin/dev' into dev 2026-09-01 15:14:24 +08:00
ouyangxq b223ce82d6 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 15:11:52 +08:00
ouyangxq 1e8f504f15 fix(firstleg): allow booking link before container pickup 2026-09-01 15:06:03 +08:00
ouyangxq 94395faeef fix: make phase 5 permission validation mysql compatible 2026-09-01 15:00:38 +08:00
ouyangxq 579c61879d fix: include phase 3 customs baseline in unified sql 2026-09-01 14:54:47 +08:00
ouyangxq 41837180f8 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:49:41 +08:00
ouyangxq a10e32a012 test(firstleg): finish task evidence migration 2026-09-01 14:48:51 +08:00
ouyangxq 980d37b9c2 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:48:33 +08:00
ouyangxq f2958bef6e 优化 2026-09-01 14:47:45 +08:00
ouyangxq 0e1ad4ed45 优化 2026-09-01 14:47:43 +08:00
ouyangxq 76d2da5bf9 test(firstleg): use task-scoped loading evidence 2026-09-01 14:46:48 +08:00
ouyangxq 46f5a73c47 优化 2026-09-01 14:46:44 +08:00
ouyangxq b610a128de Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 14:46:36 +08:00
ouyangxq f0ac0c42c9 test(firstleg): use valid PDF truck fixtures 2026-09-01 14:44:30 +08:00
ouyangxq 105660bd09 test(firstleg): scope loading evidence by cabinet 2026-09-01 14:37:02 +08:00
ouyangxq 2ccfc38cdf test(firstleg): seed booking file before loading release 2026-09-01 14:31:01 +08:00
ouyangxq f551cd8c7a 优化 2026-09-01 14:27:16 +08:00
ouyangxq d2092bf0b6 优化 2026-09-01 14:27:04 +08:00
ouyangxq 97168b19d8 feat: complete phase 5 overseas fulfillment contracts 2026-09-01 14:24:59 +08:00
liuyihe 8b0c99593e 一件代发拣货 2026-09-01 13:41:00 +08:00
ouyangxq 9e32efdd90 test(firstleg): harden measurement demo package 2026-09-01 12:03:21 +08:00
ouyangxq 6eaf484fcd test(firstleg): package carrier node demo checks 2026-09-01 12:02:57 +08:00
ouyangxq 53078b4281 test(firstleg): extend shipment demo regression 2026-09-01 12:02:47 +08:00
ouyangxq 49d216cc13 test(order): harden demo order lifecycle fixture 2026-09-01 12:02:35 +08:00
ouyangxq 9a3b408de0 优化 2026-09-01 11:38:42 +08:00
ouyangxq 68b584128c fix(firstleg): surface existing truck appointment 2026-09-01 11:30:35 +08:00
ouyangxq b5bc77b153 fix(firstleg): atomically finalize PDA completion 2026-09-01 11:11:43 +08:00
ouyangxq ffed9bf898 Merge branch 'dev-lyhfive' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 11:09:14 +08:00
ouyangxq de0eddeee8 修复尾程货主快照与短单号合同 2026-09-01 11:06:26 +08:00
ouyangxq 4991536065 test(firstleg): harden pallet option page contract 2026-09-01 11:03:40 +08:00
ouyangxq 7ace1ded56 优化 2026-09-01 11:02:10 +08:00
ouyangxq 5af79df4bd test(firstleg): align trucking attachment regressions 2026-09-01 11:01:34 +08:00
ouyangxq 6eb53751a3 test(pda): keep overseas acceptance business-scoped 2026-09-01 11:01:20 +08:00
ouyangxq 09e81b429c test(firstleg): align fulfillment matrix with container customs 2026-09-01 10:48:33 +08:00
ouyangxq 179a20fc38 refactor(firstleg): keep trucking payable only 2026-09-01 10:38:15 +08:00
liuyihe f86fab838a 一件代发拣货 2026-09-01 10:35:54 +08:00
ouyangxq 6a0f51f136 优化 2026-09-01 10:29:14 +08:00
ouyangxq 7d9eaa7f43 test(firstleg): verify trucking detail after dispatch 2026-09-01 10:26:22 +08:00
ouyangxq bfc2a4dbd4 test(firstleg): scope trucking warehouse lookup 2026-09-01 10:22:15 +08:00
ouyangxq 0613d1095e test(firstleg): resolve trucking fixtures from active catalogs 2026-09-01 10:21:37 +08:00
ouyangxq 3d7990250f fix(finance): align operating fund export labels 2026-09-01 10:19:35 +08:00
ouyangxq 2c3efad70a test: bound A0 location isolation writes 2026-09-01 10:18:19 +08:00
ouyangxq cdcf3e0f4f test(firstleg): parse platform warehouse scope explicitly 2026-09-01 10:17:41 +08:00
ouyangxq 3d6216910f test(finance): align full acceptance with current workflows 2026-09-01 10:17:03 +08:00
ouyangxq 505188c3fd test(firstleg): make trucking acceptance self-contained 2026-09-01 10:16:07 +08:00
ouyangxq 477971dda1 优化 2026-09-01 10:00:13 +08:00
liuyihe 2f1bda8369 一件代发拣货 2026-09-01 09:57:10 +08:00
ouyangxq 249b182e7f refactor(firstleg): remove customer scope from trucking 2026-09-01 09:51:59 +08:00
ouyangxq 936412f351 test(file-print): extend booking document regression 2026-08-31 20:56:52 +08:00
ouyangxq 0cc4eae209 feat(firstleg): enforce booking-owned truck workflow 2026-08-31 19:55:58 +08:00
ouyangxq f2a980e5c1 fix(file): allow arbitrary ocean booking documents 2026-08-31 19:54:05 +08:00
ouyangxq 47ce84d122 feat(file-print): support ocean booking documents 2026-08-31 19:30:37 +08:00
ouyangxq 01385c4bc7 优化 2026-08-31 18:55:03 +08:00
ouyangxq 9b09663b2e fix(firstleg): allow corrective evidence after completion failure 2026-08-31 18:42:08 +08:00
ouyangxq 838bd93bc4 test(firstleg): align cabinet evidence SQL contracts 2026-08-31 18:31:39 +08:00
ouyangxq 60d45613c7 fix(firstleg): import multipart request annotation 2026-08-31 18:24:58 +08:00
ouyangxq 4ddfc6fb99 chore(firstleg): remove legacy inbound evidence schema 2026-08-31 18:19:25 +08:00
ouyangxq df0fbc1c7f fix(firstleg): scope loading evidence to cabinet tasks 2026-08-31 18:14:36 +08:00
ouyangxq 4795ab958c fix(file): require Firstleg for PDA evidence access 2026-08-31 17:42:19 +08:00
ouyangxq 1288c8937c fix(file): bind loading evidence to cabinet 2026-08-31 17:33:11 +08:00
ouyangxq ba8e3595e4 fix(firstleg): explain coload group eligibility 2026-08-31 15:40:24 +08:00
ouyangxq 17d81b0b94 feat(order): expose unified order channel code 2026-08-31 15:38:18 +08:00
ouyangxq c9792b1f63 fix(firstleg): resolve loading list lifecycle by plan 2026-08-31 15:26:47 +08:00
liuyihe a213be67c5 尾程暂扣回传 2026-08-31 15:22:32 +08:00
ouyangxq 0122ad314b feat(firstleg): expose smart-loading channel directory 2026-08-31 14:54:00 +08:00
ouyangxq ea615b864a fix(firstleg): cancel coload groups by inbound code 2026-08-31 14:53:44 +08:00
liuyihe a4189a0074 提交 2026-08-31 14:39:40 +08:00
ouyangxq 314ce09b5d fix(firstleg): load container overview transport history 2026-08-31 12:39:24 +08:00
ouyangxq 942dbace19 feat: enforce canonical container identity across regions 2026-08-31 12:13:20 +08:00
ouyangxq b386f35ab4 优化 2026-08-31 10:31:51 +08:00
ouyangxq d6f47e650c 优化 2026-08-31 10:19:48 +08:00
ouyangxq bef91151a3 优化 2026-08-31 10:17:42 +08:00
ouyangxq bf1dc1f209 优化 2026-08-31 10:17:03 +08:00
ouyangxq aa583efa4a 优化 2026-08-31 10:09:14 +08:00
liuyihe a4429f95b7 提交 2026-08-30 18:49:15 +08:00
ouyangxq ff6a8498a6 fix(lastleg): resolve actual overseas receiving warehouse 2026-08-30 18:07:38 +08:00
ouyangxq ca9e7b60e8 优化 2026-08-30 17:02:19 +08:00
ouyangxq 0aa77327d5 test(firstleg): stabilize coload smart and short-pick checks 2026-08-30 16:14:07 +08:00
ouyangxq 5c83b81e8a test(firstleg): harden coload capacity acceptance 2026-08-30 16:07:12 +08:00
ouyangxq c9ee319e9d fix(firstleg): refresh recommendation capacity 2026-08-30 16:06:20 +08:00
ouyangxq 4581c8a294 test(firstleg): assert atomic smart coload recommendation 2026-08-30 15:58:14 +08:00
ouyangxq 9a15e78b2e test(firstleg): extend coload group acceptance 2026-08-30 15:55:48 +08:00
ouyangxq bfcb80b326 fix(firstleg): align coload group eligibility 2026-08-29 12:44:42 +08:00
ouyangxq 0cf76df168 fix(firstleg): align coload loading route gates 2026-08-29 12:36:07 +08:00
ouyangxq ba641754de test(firstleg): document coload PDA acceptance 2026-08-29 11:59:53 +08:00
ouyangxq 81ef43c9fd 优化 2026-08-29 11:59:11 +08:00
ouyangxq 0605c32829 fix(firstleg): persist pricing conversion metadata 2026-08-29 11:48:19 +08:00
ouyangxq 66816d5341 优化 2026-08-29 11:42:52 +08:00
ouyangxq de101621dd fix(firstleg): align merchant billing with sent fees 2026-08-29 11:13:35 +08:00
ouyangxq b4db37392d fix(firstleg): complete coload SQL comments 2026-08-29 11:13:10 +08:00
ouyangxq b1ab909184 优化 2026-08-29 11:10:28 +08:00
ouyangxq 775dd71227 fix(firstleg): keep published billing visible after loading 2026-08-29 11:00:19 +08:00
ouyangxq 3339ec91f9 feat(firstleg): enforce indivisible coload loading 2026-08-29 10:59:38 +08:00
ouyangxq ac6d3289a6 feat(firstleg): add indivisible coload groups 2026-08-29 10:51:50 +08:00
ouyangxq e6ee9cb442 fix(firstleg): recover multi-customer PDA completion 2026-08-29 08:25:42 +08:00
ouyangxq da32a67d7f 优化 2026-08-28 23:01:52 +08:00
ouyangxq 969ff05815 test(firstleg): correct mixed customs completion probe 2026-08-28 22:54:23 +08:00
ouyangxq 7f565a24d6 test(firstleg): fix mixed customs permission probe 2026-08-28 22:50:55 +08:00
ouyangxq 1e389d95dd fix(firstleg): serialize concurrent customs start replay 2026-08-28 22:44:37 +08:00
ouyangxq f53ff76143 test(firstleg): add mixed container customs acceptance 2026-08-28 22:39:18 +08:00
ouyangxq f87647d4eb fix(firstleg): allow customs uploads up to 50MB 2026-08-28 22:15:24 +08:00
ouyangxq 833f928a30 fix(file): align customs download permission contract 2026-08-28 22:12:29 +08:00
ouyangxq 621aec9b2b fix(firstleg): replay completed customs uploads 2026-08-28 21:42:51 +08:00
ouyangxq f72238e43f fix(firstleg): map customs document versions explicitly 2026-08-28 21:20:43 +08:00
ouyangxq 7a15481e2d fix(firstleg): resolve merchant customs plan lookup 2026-08-28 17:28:39 +08:00
ouyangxq 2dca7dfdb2 优化 2026-08-28 17:10:12 +08:00
ouyangxq 1c51373704 fix(firstleg): register container customs mapper 2026-08-28 17:10:07 +08:00
ouyangxq 9664aa4bf0 test(firstleg): align customs contract assertions 2026-08-28 16:59:43 +08:00
ouyangxq 603bd29cc2 优化 2026-08-28 16:54:21 +08:00
ouyangxq 0afe75379e test(order): align batch draft service mocks 2026-08-28 16:53:28 +08:00
ouyangxq 0ea4aeecc9 feat(firstleg): implement container customs workflow 2026-08-28 16:48:50 +08:00
liuyihe 5ddaa4d51f 提交 2026-08-28 15:25:50 +08:00
liuyihe 8a7c92bb0f 提交 2026-08-28 15:16:36 +08:00
liuyihe 4f2b670ebf 暂扣 标记合托功能 2026-08-28 14:55:18 +08:00
liuyihe 66b9c3aa77 暂扣 标记合托功能 2026-08-28 14:20:48 +08:00
ouyangxq bb9673358d 优化 2026-08-28 13:58:49 +08:00
ouyangxq 6b5f2f0565 优化 2026-08-28 13:54:32 +08:00
ouyangxq fe68551124 优化 2026-08-28 12:33:14 +08:00
ouyangxq f998bed09c 优化 2026-08-28 11:28:30 +08:00
ouyangxq 1e5ce54858 优化 2026-08-28 10:38:58 +08:00
ouyangxq b34fbb2bd1 fix(ci): ignore stale Surefire reports 2026-08-28 10:32:41 +08:00
ouyangxq a6f266687e 优化 2026-08-28 10:03:32 +08:00
liuyihe 6a83008ba6 提交 2026-08-28 09:59:17 +08:00
ouyangxq dd117b8fc2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-28 09:19:19 +08:00
ouyangxq a719a903cd 优化 2026-08-28 09:19:18 +08:00
wujinliang 3b1b4ab9b2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-27 21:49:00 +08:00
wujinliang 9d54c57b7c 1 2026-08-27 21:48:47 +08:00
ouyangxq e72ac14425 fix: map warehouse pallet lookup failure 2026-08-27 19:31:38 +08:00
ouyangxq ec8f84c1c0 fix: reject missing receiving pallet 2026-08-27 19:27:59 +08:00
ouyangxq 2b7f66cb04 fix: reclaim newly added container plans 2026-08-27 19:14:54 +08:00
ouyangxq c7061b8a74 优化 2026-08-27 18:18:14 +08:00
ouyangxq 5250f304be 优化 2026-08-27 18:11:57 +08:00
ouyangxq f5f72d681d 优化 2026-08-27 18:05:02 +08:00
ouyangxq c226b6a99b 优化 2026-08-27 17:49:02 +08:00
ouyangxq 600b2a2dc4 优化 2026-08-27 17:31:19 +08:00
liuyihe 5f341d6fb5 一件代发 2026-08-27 17:23:18 +08:00
ouyangxq 76c24867a4 优化 2026-08-27 17:16:33 +08:00
liuyihe 8d39c61863 提交 2026-08-27 16:54:03 +08:00
ouyangxq 61a97cd057 优化 2026-08-27 16:42:27 +08:00
ouyangxq ecf48886d5 优化 2026-08-27 16:34:30 +08:00
ouyangxq 1db3e48506 优化 2026-08-27 16:17:11 +08:00
ouyangxq 0f2e201c38 fix(lastleg): unblock inbound plan handoff 2026-08-27 15:56:14 +08:00
ouyangxq adabec13d3 fix(print): include Spring DAO exceptions at runtime 2026-08-27 15:21:28 +08:00
ouyangxq 0fb51211aa 优化 2026-08-27 15:02:37 +08:00
ouyangxq d7bff980ca 优化 2026-08-27 14:53:27 +08:00
ouyangxq ed3c1dafcc 优化 2026-08-27 14:30:33 +08:00
ouyangxq 0a3ec0b8a6 优化 2026-08-27 14:30:23 +08:00
ouyangxq 3b38fdd33d 优化 2026-08-27 13:50:33 +08:00
liuyihe 7e0911951d Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
2026-08-27 13:40:26 +08:00
liuyihe a79999d97c 提交 2026-08-27 13:35:52 +08:00
ouyangxq daf0ee2acd 优化 2026-08-27 11:45:52 +08:00
ouyangxq 086a0f9c89 优化 2026-08-27 11:42:05 +08:00
ouyangxq c80307a838 优化 2026-08-27 11:39:32 +08:00
ouyangxq 575cee7a86 优化 2026-08-27 11:38:53 +08:00
ouyangxq c1b136c3d6 优化 2026-08-27 11:09:51 +08:00
ouyangxq 4612181ab0 优化 2026-08-27 11:07:57 +08:00
ouyangxq b4c46280de test(acceptance): cover booking agent carrier matrix 2026-08-27 10:38:55 +08:00
ouyangxq 15b563610c fix(firstleg): validate booking carrier associations 2026-08-27 10:30:38 +08:00
ouyangxq 079ff43f73 feat(warehouse): support supplier carrier associations 2026-08-27 10:29:28 +08:00
ouyangxq 554b6c93b7 优化 2026-08-27 09:21:01 +08:00
ouyangxq 817e356c21 Merge remote-tracking branch 'origin/dev' into test 2026-08-26 21:17:25 +08:00
ouyangxq 9b6e257f8d fix(db): support clean test database initialization 2026-08-26 21:17:14 +08:00
ouyangxq ca12f58d38 chore: remove tracked runtime artifacts 2026-08-26 20:51:42 +08:00
ouyangxq 8fb63e9adc Merge remote-tracking branch 'origin/dev' into test
# Conflicts:
#	warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java
#	warehouse/src/main/resources/db/warehouse.sql
2026-08-26 20:51:27 +08:00
ouyangxq 0103ea96c8 test: lock overseas PDA pallet routing 2026-08-26 20:39:03 +08:00
liuyihe 3975759458 提交 2026-08-26 19:46:14 +08:00
wujinliang d736fdba1b 1 2026-08-26 18:12:55 +08:00
ouyangxq e9b03a3d35 test: allow inbound operation descriptions 2026-08-26 18:07:39 +08:00
ouyangxq 29c27d2fd7 fix: resolve FBA PDA carton by inbound prefix 2026-08-26 18:03:17 +08:00
ouyangxq cbcbb9c764 fix(ci): make AUTO deployment incremental 2026-08-26 18:01:47 +08:00
ouyangxq 64047df5b3 优化 2026-08-26 17:50:56 +08:00
ouyangxq aad8dd6344 优化 2026-08-26 17:31:12 +08:00
ouyangxq 9df87b17ac 优化 2026-08-26 17:31:09 +08:00
ouyangxq 2280154773 优化 2026-08-26 17:29:07 +08:00
ouyangxq 05e3aa4e8d 优化 2026-08-26 17:27:09 +08:00
ouyangxq ef45325643 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-08-26 17:27:05 +08:00
ouyangxq 92d7c0a7b0 优化 2026-08-26 17:23:38 +08:00
ouyangxq 795e852944 优化 2026-08-26 17:09:02 +08:00
ouyangxq 6c4543c18c 优化 2026-08-26 16:50:22 +08:00
liuyihe 63182a2b88 Merge branch 'dev' into dev-lyhfive 2026-08-26 16:07:27 +08:00
liuyihe b04c58a76a Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
2026-08-26 16:05:24 +08:00
ouyangxq fd1a81594a 优化 2026-08-26 15:58:54 +08:00
liuyihe 5c6c0ef5ca 提交 2026-08-26 15:51:46 +08:00
ouyangxq 57a2f6ef28 fix(firstleg): enforce strict manual node progression 2026-08-26 15:44:05 +08:00
ouyangxq 2ebea9e789 fix(firstleg): allow audited carrier node time corrections 2026-08-26 15:41:03 +08:00
wujinliang f2877df697 1 2026-08-26 15:32:10 +08:00
ouyangxq 4ae236fe11 优化 2026-08-26 15:24:48 +08:00
liuyihe 93e1540e3f 提交 2026-08-26 15:02:14 +08:00
ouyangxq 3f9ee28d22 优化 2026-08-26 14:56:33 +08:00
ouyangxq 36534fe9ce fix(warehouse): align partner import carrier columns 2026-08-26 14:55:43 +08:00
ouyangxq 1f98c5ab9c 优化 2026-08-26 14:49:11 +08:00
ouyangxq bf960f10d1 优化 2026-08-26 14:48:43 +08:00
liuyihe e31fd8caa2 提交 2026-08-26 14:36:04 +08:00
ouyangxq 0f4524c795 优化 2026-08-26 14:30:55 +08:00
ouyangxq 14126dce5b 优化 2026-08-26 14:30:35 +08:00
ouyangxq dc564c9530 优化 2026-08-26 14:12:05 +08:00
ouyangxq 7f05183904 优化 2026-08-26 14:08:41 +08:00
ouyangxq 4398e8b347 优化 2026-08-26 13:58:33 +08:00
liuyihe 394c690601 提交 2026-08-26 13:51:19 +08:00
ouyangxq fc0a6ea7ca fix(firstleg): expose container documents and aggregate departure gates 2026-08-26 13:07:30 +08:00
ouyangxq 1584c7a0a3 fix(firstleg): make truck status informational for departure 2026-08-26 12:36:14 +08:00
ouyangxq 8eeb52ba77 feat(firstleg): confirm domestic departure by container 2026-08-26 12:05:42 +08:00
ouyangxq 9ea2aa46ec Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-26 11:11:03 +08:00
ouyangxq cf5d603a21 优化 2026-08-26 11:10:42 +08:00
ouyangxq 15421db421 fix(event-bridge): restore overseas event delivery 2026-08-26 11:06:24 +08:00
liuyihe 270372f56a 提交 2026-08-26 11:05:26 +08:00
wujinliang 897ddd476f 1 2026-08-25 22:08:44 +08:00
ouyangxq 44fee01761 test: align active booking allocation contract 2026-08-25 20:21:42 +08:00
ouyangxq 5d2b6c22e6 test: freeze server-filtered booking candidates 2026-08-25 20:17:12 +08:00
ouyangxq ead9565a37 fix: align ocean booking link eligibility 2026-08-25 20:13:48 +08:00
liuyihe 3bf68c1083 提交 2026-08-25 19:58:08 +08:00
ouyangxq 6e55d94f33 fix: add ocean booking link candidates query 2026-08-25 19:44:10 +08:00
ouyangxq 7d36759f3f 优化 2026-08-25 19:07:34 +08:00
ouyangxq 65c01f8b3d test: isolate phase4 carrier fixtures 2026-08-25 18:32:12 +08:00
ouyangxq b6226f1ca6 fix: replay completed pallet operations 2026-08-25 18:21:39 +08:00
ouyangxq ca69434698 fix: persist PDA movement state semantics 2026-08-25 18:11:29 +08:00
ouyangxq 335ad8269f fix: execute overseas pallet transitions 2026-08-25 18:07:48 +08:00
ouyangxq 7471f7e81f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-25 17:55:27 +08:00
ouyangxq 62407610cb 优化 2026-08-25 17:54:50 +08:00
wujinliang 0def6114ee 1 2026-08-25 17:42:38 +08:00
ouyangxq 25cb3bfa54 test: await PDA projection and pending receipts 2026-08-25 17:25:38 +08:00
ouyangxq 4e8c15dbfa test: wait for customer dimension initialization 2026-08-25 17:17:32 +08:00
ouyangxq 3ab1a9d173 fix(lastleg): remove duplicate vehicle repository mapper 2026-08-25 17:01:52 +08:00
ouyangxq 505d448850 优化 2026-08-25 16:41:02 +08:00
ouyangxq 2dcfdd5fc8 优化 2026-08-25 16:40:54 +08:00
ouyangxq eb1a8895ac 优化 2026-08-25 16:40:50 +08:00
ouyangxq 87c5e18ca6 优化 2026-08-25 16:40:48 +08:00
ouyangxq 1f6f1a0677 test: complete phase4 PDA shipping prerequisites 2026-08-25 15:27:26 +08:00
ouyangxq fa92936406 fix(deploy): bind development gateways to loopback 2026-08-25 15:16:41 +08:00
ouyangxq 44198866d9 优化 2026-08-25 15:08:34 +08:00
ouyangxq e815abcbd7 fix(firstleg): retain dropship carton label pool 2026-08-25 15:07:06 +08:00
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 39988cbaa5 fix(firstleg): publish only actually loaded items 2026-08-25 15:00:03 +08:00
ouyangxq a067a9f056 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
2026-08-25 14:53:27 +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 015fc09091 补充PDA装柜完成阶段诊断 2026-08-24 20:47:13 +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
2528 changed files with 228948 additions and 30323 deletions
+3
View File
@@ -1,4 +1,5 @@
target/
node_modules/
.docker-context/
*.log
.idea/
@@ -13,6 +14,7 @@ logs/
data/
.env
.env.*
.env.development
!.env.example
!.env.*.example
application-local.yml
@@ -27,5 +29,6 @@ reports/
uploads/
.codex-temp*
.codex-tmp*
.runtime-*/
.codex-*.out
.codex-*.err
+57 -1
View File
@@ -13,6 +13,55 @@ cache:
paths:
- .m2/repository/
Java拓扑合同:
stage: verify
image: node:22-alpine
cache: []
script:
- node --test scripts/java-topology-contract.test.mjs
OpenAPI有效性:
stage: verify
image: node:22-alpine
cache: []
script:
- npm ci --ignore-scripts --prefix scripts/openapi-validation
- npm test --prefix scripts/openapi-validation
- npm run validate --prefix scripts/openapi-validation
静态分析报告:
stage: verify
allow_failure: true
script:
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
artifacts:
when: always
expire_in: 7 days
paths:
- "**/target/spotbugs*.xml"
- "**/target/site/spotbugs.html"
质量摘要:
stage: verify
image: node:22-alpine
cache: []
allow_failure: true
needs:
- job: OpenAPI有效性
artifacts: false
- job: 校验与测试
artifacts: true
- job: 静态分析报告
artifacts: true
script:
- node --test scripts/java-quality-summary.test.mjs
- node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs
artifacts:
when: always
expire_in: 7 days
paths:
- target/java-quality-summary.json
校验与测试:
stage: verify
script:
@@ -25,10 +74,14 @@ cache:
- "**/target/surefire-reports/TEST-*.xml"
paths:
- "**/target/surefire-reports/"
- "**/target/site/jacoco/"
- "**/target/jacoco.exec"
打包服务:
stage: package
needs:
- Java拓扑合同
- OpenAPI有效性
- 校验与测试
script:
- mvn $MAVEN_CLI_OPTS -DskipTests package
@@ -36,14 +89,17 @@ cache:
expire_in: 7 days
paths:
- gateway/target/*.jar
- gateway-overseas/target/*.jar
- auth/target/*.jar
- iam/target/*.jar
- order/target/*.jar
- firstleg/target/*.jar
- finance/target/*.jar
- warehouse/target/*.jar
- warehouse-overseas-service/target/*.jar
- lastleg-service/target/*.jar
- file/target/*.jar
- print/target/*.jar
- audit-service/target/*.jar
- integration-service/target/*.jar
- event-bridge-service/target/*.jar
+90 -19
View File
@@ -1,67 +1,138 @@
# 国内头程后端项目指引
# 头程与海外仓后端项目指引
## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 不可突破的产品与数据边界
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
| 任务关键词 | 首选服务 | 先看入口 |
|---|---|---|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 |
| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
| 海外入仓分页、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 出库或派送 |
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
## 公共模块快速索引
| 关键词 | 模块与入口 |
|---|---|
| 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 |
| 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 |
| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 |
| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
| 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` |
## 全量验收路由
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 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` 不读取尾程订单库。
## 契约、数据库与验证入口
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Client 快速下单定向验收使用 `scripts/client-quick-order-test-env-acceptance.mjs`,只验证批量草稿接口、逐行隔离和幂等行为,不提交、不审核,也不替代 Client 全量入口。
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
## 阶段文档入口
## 业务域文档入口
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
- 入库测量与计费:`../docs/入库测量与计费/README.md`、`../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/国内货柜履约开工门禁.md`、`../docs/国内货柜履约/国内货柜履约开发规划.md`、`../docs/国内货柜履约/国内货柜履约需求确认表.md`、页面与原型清单
- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+259 -60
View File
@@ -1,10 +1,11 @@
def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service'
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
]
def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY',
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG',
@@ -14,23 +15,133 @@ def serviceCheckboxes = [
'file': 'SERVICE_FILE',
'print': 'SERVICE_PRINT',
'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT'
]
def commonPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
'Jenkinsfile.service', 'deploy/'
def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml'
]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
],
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
if (!description?.startsWith('AUTO ')) return null
int markerIndex = description.indexOf(' | ')
if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String state = description.substring(markerIndex + ' | '.length()).trim()
List<String> failedServices = []
if (state.startsWith('RESULTS')) {
String summary = state.substring('RESULTS'.length()).trim()
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
String service = item.substring(0, separator).trim()
String result = item.substring(separator + 1).trim()
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
} else if (state.startsWith('PENDING')) {
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
String selected = state.substring('PENDING'.length()).trim()
int suffixIndex = selected.indexOf(' | ')
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
failedServices.addAll(selected.split(',').collect { it.trim() }
.findAll { allowedServices.contains(it) })
} else {
return null
}
return [commit: commit, failedServices: failedServices.unique()]
}
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
if (state != null) return state
candidate = candidate.previousBuild
inspected++
}
return null
}
def environmentChoicesForJob(String jobBaseName) {
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
}
def fixedEnvironmentForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java-dev':
return 'development'
case 'backend-java':
return 'test'
case 'backend-java-prod':
return 'production'
default:
return null
}
}
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java-dev':
return 'baiyue-firstleg-dev-'
case 'backend-java':
return 'baiyue-firstleg-'
case 'backend-java-prod':
return 'baiyue-firstleg-prod-'
default:
return null
}
}
def downstreamJobPrefixChoicesForJob(String jobBaseName) {
return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-']
}
pipeline {
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
agent any
agent none
options {
disableConcurrentBuilds()
@@ -41,6 +152,7 @@ pipeline {
parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
@@ -50,43 +162,65 @@ pipeline {
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
stages {
stage('确定构建范围') {
agent { label 'java-builder' }
steps {
script {
String requestedRepository
if (params.ENVIRONMENT == 'test') {
requestedRepository = 'baiyue-firstleg-test'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry'
} else {
requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('预发布/生产环境必须填写真实镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
if (!resolvedEnvironment || !resolvedJobPrefix) {
error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
}
if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}"
}
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2
exit 1
}
'''
}
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('必须填写真实镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
if (!jobPrefix) {
error('DOWNSTREAM_JOB_PREFIX 不能为空')
}
env.RESOLVED_JOB_PREFIX = jobPrefix
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -98,9 +232,18 @@ pipeline {
error('SELECT 模式至少需要勾选一个服务')
}
} else {
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
echo 'AUTO 未找到可用基线,安全回退为全量构建'
} else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it }
@@ -108,13 +251,27 @@ pipeline {
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
boolean commonChanged = changed.any { path ->
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
List serviceRelevantChanges = changed.findAll { path ->
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") }
boolean fullRebuild = serviceRelevantChanges.any { path ->
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
if (fullRebuild) {
selected = serviceModules
} else {
selected = serviceModules.findAll { module ->
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
}
commonPathServices.each { prefix, impactedServices ->
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
selected.addAll(impactedServices)
}
}
}
selected.addAll(retryServices)
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
}
}
@@ -124,17 +281,32 @@ pipeline {
}
selected = selected.unique()
if (params.DEPLOY && selected.contains('event-bridge-service')) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
if (bridgeRegion == 'SELECT') {
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
}
}
env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS"
: (env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务')
} else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
}
if (env.DATABASE_CHANGE_DETECTED == 'true') {
@@ -144,25 +316,41 @@ pipeline {
}
}
stage('生产环境部署确认(仅 production 执行)') {
stage('Gateway 路由静态门禁') {
when {
allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
expression { env.NO_SERVICE_CHANGE != 'true' }
}
agent { label 'java-builder' }
steps {
input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署'
sh 'node --test scripts/gateway-route-contract.test.mjs'
}
}
stage('测试数据库初始化(可选)') {
stage('统一 Maven 测试门禁') {
when {
allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
}
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
steps {
script {
String selectedModules = env.SELECTED_SERVICES.split(',')
.collect { it.trim() }
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
}
}
steps {
sh 'sh deploy/scripts/initialize-test-databases.sh'
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
@@ -175,6 +363,14 @@ pipeline {
String service = selectedService
branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
boolean deployBothBridges = service == 'event-bridge-service' &&
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
String downstreamImageTag = deployBothBridges
? "${env.IMAGE_TAG}-both"
: env.IMAGE_TAG
String downstreamBridgeRegion = deployBothBridges
? 'CN'
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build(
job: jobName,
@@ -182,12 +378,16 @@ pipeline {
propagate: false,
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT),
string(name: 'IMAGE_TAG', value: downstreamImageTag),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
@@ -207,7 +407,9 @@ pipeline {
.collect { service, result -> "${service}=${result}" }
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
echo "单服务执行汇总: ${summary}"
currentBuild.description = "${env.IMAGE_TAG} | ${summary}"
currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
: "${env.IMAGE_TAG} | ${summary}"
if (failed) {
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
}
@@ -216,7 +418,4 @@ pipeline {
}
}
post {
always { cleanWs(deleteDirs: true, notFailBuild: true) }
}
}
+249 -107
View File
@@ -1,10 +1,11 @@
def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service'
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
]
def serviceJars = [
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
@@ -14,17 +15,32 @@ def serviceJars = [
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
]
pipeline {
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。
agent any
tools {
maven 'maven-3.8.8'
def fixedEnvironmentForServiceJob(String jobBaseName) {
if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) {
return 'development'
}
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) {
return 'production'
}
if (jobBaseName?.startsWith('baiyue-firstleg-')) {
return 'test'
}
return null
}
def environmentChoicesForServiceJob(String jobBaseName) {
return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test']
}
pipeline {
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent none
options {
skipDefaultCheckout(true)
@@ -36,117 +52,167 @@ pipeline {
parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
environment {
REGISTRY_CREDENTIALS = 'gitea-test-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
}
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
stage('构建并发布单服务') {
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim()
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim()
String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME)
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://')
}
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) {
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!resolvedEnvironment) {
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
}
if (params.ENVIRONMENT != resolvedEnvironment) {
echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}"
}
boolean deployBothBridges = service == 'event-bridge-service' &&
imageTag?.endsWith('-both')
if (deployBothBridges) {
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
env.RESOLVED_SERVICE = service
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.RESOLVED_SERVICE = service
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
if (service == 'event-bridge-service' && params.DEPLOY) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
!['development', 'test'].contains(env.RESOLVED_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}")"
'''
sh '''
if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || {
echo "生产构建只允许已经合并到 main 的提交" >&2
exit 1
}
fi
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
}
}
}
sh '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}"
}
}
}
stage('Maven 测试与打包') {
steps {
script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
stage('Maven 测试与打包') {
steps {
script {
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
}
boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (env.RESOLVED_ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
String dockerConfigDir = "${pwd(tmp: true)}/docker-auth"
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
if (params.ENVIRONMENT == 'test') {
echo "测试镜像已保存在当前 Docker 节点:${image}"
} else {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
sh "docker push ${image}"
} finally {
sh "docker logout ${registryHost} || true"
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
}
}
}
@@ -154,25 +220,101 @@ pipeline {
}
}
stage('部署单个服务') {
when { expression { params.DEPLOY } }
stage('部署开发或测试环境') {
when {
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
}
agent none
steps {
script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot"
String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
lock(resource: serviceDeployResource, inversePrecedence: true) {
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
node(targetLabel) {
deleteDir()
try {
unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}"
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
? ['CN', 'OVERSEAS']
: [params.EVENT_BRIDGE_REGION]
withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) {
bridgeRegions.each { bridgeRegion ->
if (env.RESOLVED_SERVICE == 'event-bridge-service') {
echo "部署事件桥实例: ${bridgeRegion}"
}
withEnv([
'TARGET_IS_LOCAL=true',
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
}
}
}
stage('部署生产环境单个服务') {
when {
beforeAgent true
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' }
}
agent { label 'java-builder' }
steps {
deleteDir()
unstash 'deployment-files'
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy"
lock(resource: serviceDeployResource, inversePrecedence: true) {
withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
withEnv([
'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services',
'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
}
}
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
post {
success {
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
}
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
+26 -6
View File
@@ -1,6 +1,6 @@
# 柏悦头程平台
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
@@ -12,23 +12,43 @@
- `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
- `platform-bom`:统一管理仓库内公共依赖版本。
## 本地构建
```powershell
mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test
```
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖:
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|---|---|---:|---:|---|
| 开发 | `json-file` | `20m` | `2` | 不要求 |
| 测试 | `json-file` | `50m` | `3` | 不要求 |
| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 |
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
## 网关跨域配置
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
@@ -41,4 +61,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
+1
View File
@@ -2,6 +2,7 @@
## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
@@ -1,15 +1,19 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan("com.baiyue.audit.mapper")
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
String username,
String appCode,
String clientType,
String clientIp,
String requestId,
String traceId,
String serviceName,
@@ -1,10 +1,13 @@
package com.baiyue.audit.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity;
/**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
}
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
@@ -40,14 +40,3 @@ baiyue:
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,17 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueAuditApplicationContractTest {
@Test
void importsSharedMybatisPaginationConfiguration() {
Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class);
assertThat(imported).isNotNull();
assertThat(imported.value()).contains(MybatisPlusConfiguration.class);
}
}
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List;
import java.util.Set;
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
verify(service).detail(8L);
}
@Test
void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException {
String expected = "hasAuthority('platform:audit:list')";
assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class,
long.class, long.class, Long.class, Long.class, String.class, String.class, String.class,
Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
}
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData");
}
@Test
void pagePreservesTotalAndPagesForCustomerLogPagination() {
AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null,
null, null, null);
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test
void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L));
+10 -3
View File
@@ -2,8 +2,10 @@
## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
@@ -12,7 +14,11 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -22,10 +28,11 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
## 全量验收导航
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。
@@ -1,5 +1,6 @@
package com.baiyue.auth;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
*/
@MapperScan("com.baiyue.auth.mapper")
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueAuthApplication {
@@ -21,4 +21,12 @@ public class AuthorizationSnapshot {
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
}
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -16,14 +17,20 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request);
@GetMapping("/merchant-registrations/{authUserId}")
ApiResponse<OnboardingResponse> registration(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId);
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail,
String legalPerson, String administratorPhone, String contactEmail,
String countryCode, String address) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
String dimensionInitializationStatus) { }
}
@@ -77,7 +77,7 @@ public class AuthController {
@SecurityRequirements
@PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request));
}
@@ -3,12 +3,16 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService;
import com.baiyue.auth.service.OverseasSessionValidationService;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -18,11 +22,14 @@ import java.security.MessageDigest;
/** 仅供可信内部服务调用的账号管理接口。 */
@RestController
@RequestMapping("/internal/auth/accounts")
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
public class InternalAccountController {
@Resource
private AccountManagementService service;
@Resource
private AuthService authService;
@Resource
private OverseasSessionValidationService overseasSessionValidationService;
@Value("${baiyue.security.internal-service-token}")
private String configuredToken;
@@ -64,11 +71,34 @@ public class InternalAccountController {
@Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
}
@PostMapping("/overseas-session/validate")
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request));
}
@PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
}
@PutMapping("/{id}/login-identifiers")
public ApiResponse<AccountSummary> loginIdentifiers(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
verify(token);
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
}
@PutMapping("/{id}/phone")
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
verify(token);
service.updatePhone(id, request);
return ApiResponse.success(null);
}
@PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId,
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) {
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
}
}
public record ChangeLoginIdentifiersRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank String currentPassword) { }
public record UpdateAccountPhoneRequest(
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest(
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status,
public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) {
this(accountId, username, displayName, status, null, null, null, null);
this(accountId, username, null, displayName, status, null, null, null, null);
}
}
}
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
*/
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
public record LoginRequest(
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
@Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
@NotBlank @Size(max = 64) String loginId,
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
) {
}
}
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
public record MerchantRegisterRequest(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson,
@NotBlank @Size(max = 32) String legalPhone,
@Size(max = 64) String legalPerson,
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
Long tenantId,
Long membershipId,
String tenantStatus,
String dimensionInitializationStatus,
String nextStep
) { }
@@ -0,0 +1,29 @@
package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
public final class OverseasSessionValidationDtos {
private OverseasSessionValidationDtos() { }
@Schema(name = "OverseasSessionValidationRequest",
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
public record Request(
@NotNull Long userId,
@NotNull Integer tokenVersion,
Long overseasWarehouseId) { }
@Schema(name = "OverseasSessionValidationResponse")
public record Response(boolean valid, FailureReason failureReason) {
public static Response accepted() { return new Response(true, null); }
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
}
public enum FailureReason {
ACCOUNT_INVALID,
TOKEN_VERSION_MISMATCH,
WAREHOUSE_UNBOUND,
WAREHOUSE_MISMATCH
}
}
@@ -2,6 +2,8 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Set;
/**
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
*/
@@ -11,6 +13,16 @@ public record TokenResponse(
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn,
@Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
) {
}
public TokenResponse {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
public class AuthAccount {
@TableId private Long id;
private String username;
private String phone;
private String passwordHash;
private String displayName;
private String accountStatus;
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthAccount;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
*/
@Mapper
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthRefreshToken;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
*/
@Mapper
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
.last("LIMIT 1"));
}
public AuthAccount findByPhone(String phone) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.last("LIMIT 1"));
}
public boolean existsByUsername(String username) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)) > 0;
}
public boolean existsByPhone(String phone) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)) > 0;
}
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)
.ne(AuthAccount::getId, accountId)) > 0;
}
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.ne(AuthAccount::getId, accountId)) > 0;
}
public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) {
@@ -71,9 +71,13 @@ public class AccountManagementService {
if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
}
if (request.phone() != null && repository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
AuthAccount account = new AuthAccount();
account.setId(request.accountId());
account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
@@ -117,6 +121,45 @@ public class AccountManagementService {
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改本人用户名和手机号", captureAfter = false)
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
AuthAccount account = required(accountId);
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
}
String username = request.username().trim();
String phone = request.phone().trim();
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
return summary(account);
}
account.setUsername(username);
account.setPhone(phone);
bumpVersion(account);
return summary(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步受管账号手机号", captureAfter = false)
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
AuthAccount account = required(accountId);
String phone = request.phone() == null ? null : request.phone().trim();
if (Objects.equals(account.getPhone(), phone)) return;
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
account.setPhone(phone);
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
@@ -185,7 +228,7 @@ public class AccountManagementService {
}
private AccountSummary summary(AuthAccount a) {
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
}
@@ -201,6 +244,7 @@ public class AccountManagementService {
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import java.security.SecureRandom;
import java.time.Instant;
@@ -49,15 +50,20 @@ public class AuthService {
private AuthorizationLoader authorizationLoader;
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) {
AuthAccount account = accountRepository.findByUsername(request.username());
String loginId = request.loginId().trim();
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
? accountRepository.findByPhone(loginId)
: accountRepository.findByUsername(loginId);
if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus()))
@@ -108,12 +114,13 @@ public class AuthService {
Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId())
.claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId())
@@ -124,8 +131,11 @@ public class AuthService {
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion())
.build();
.claim("permissionVersion", account.getTokenVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
@@ -134,8 +144,14 @@ public class AuthService {
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds());
rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
@Transactional
@@ -176,7 +192,7 @@ public class AuthService {
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
@@ -189,17 +205,31 @@ public class AuthService {
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
String overseasAccessToken, long overseasExpiresIn,
java.util.Set<String> roles,
java.util.Set<String> authorities) {
public ImpersonationTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
}
private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
}
}
@@ -35,8 +35,20 @@ public class MerchantRegistrationService {
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "Username already exists");
AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
if (usernameAccount != null && phoneAccount != null
&& usernameAccount.getId().equals(phoneAccount.getId())
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
return existingRegistration(usernameAccount);
}
if (usernameAccount != null) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (phoneAccount != null) {
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
@@ -46,7 +58,7 @@ public class MerchantRegistrationService {
try {
var response = onboardingClient.register(internalToken,
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(),
request.companyName(), request.legalPerson(), request.phone(),
request.contactEmail(), request.countryCode(), request.address()));
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
@@ -81,6 +93,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount();
account.setId(accountId);
account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
@@ -94,7 +107,7 @@ public class MerchantRegistrationService {
try {
accountRepository.insert(account);
} catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
@@ -103,7 +116,26 @@ public class MerchantRegistrationService {
throw exception;
}
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
}
private MerchantRegisterResponse existingRegistration(AuthAccount account) {
try {
var response = onboardingClient.registration(internalToken, account.getId());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
var onboarding = response.data();
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
}
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
}
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
@@ -0,0 +1,38 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Objects;
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
@Service
public class OverseasSessionValidationService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
public Response validate(Request request) {
AuthAccount account = accounts.findById(request.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
return Response.rejected(FailureReason.ACCOUNT_INVALID);
}
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
if (currentVersion != request.tokenVersion()) {
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
}
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
}
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
}
return Response.accepted();
}
}
@@ -36,21 +36,36 @@ public class OverseasTokenService {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
}
AuthorizationSnapshot authorization = authorizationLoader.load(account);
return issue(account, authorization);
}
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.build();
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(),
authorization.getRoles(), authorization.getPermissions());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn,
java.util.Set<String> roles, java.util.Set<String> authorities) {
public OverseasTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
}
-11
View File
@@ -52,14 +52,3 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,46 @@
-- Auth 单一登录标识增量:用户名或中国大陆手机号。
-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。
USE baiyue_auth;
DROP PROCEDURE IF EXISTS auth_add_login_phone;
DELIMITER $$
CREATE PROCEDURE auth_add_login_phone()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN
ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username;
END IF;
END$$
DELIMITER ;
CALL auth_add_login_phone();
DROP PROCEDURE auth_add_login_phone;
DROP PROCEDURE IF EXISTS auth_backfill_login_phone;
DELIMITER $$
CREATE PROCEDURE auth_backfill_login_phone()
BEGIN
UPDATE auth_account
SET phone = NULL
WHERE phone IS NOT NULL AND phone NOT REGEXP '^1[3-9][0-9]{9}$';
UPDATE auth_account account
JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
JOIN (
SELECT phone
FROM baiyue_iam.iam_user
WHERE deleted = 0 AND phone REGEXP '^1[3-9][0-9]{9}$'
GROUP BY phone
HAVING COUNT(DISTINCT auth_user_id) = 1
) unique_phone ON unique_phone.phone = user.phone
SET account.phone = user.phone
WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$';
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
AND index_name = 'uk_auth_account_phone') THEN
ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
END IF;
END$$
DELIMITER ;
CALL auth_backfill_login_phone();
DROP PROCEDURE auth_backfill_login_phone;
+2
View File
@@ -6,6 +6,7 @@ USE baiyue_auth;
CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username),
UNIQUE KEY uk_auth_account_phone (phone),
KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
@@ -1,4 +1,4 @@
-- Phase-one local administrator accounts.
-- Phase-one local platform administrator account. Merchant accounts are created through onboarding.
-- Development-only initial password: ChangeMe123!
-- The BCrypt hash is intentionally stored instead of plaintext.
USE baiyue_auth;
@@ -10,9 +10,7 @@ INSERT INTO auth_account (
operator_tenant_id, default_membership_id, default_app_code, deleted
) VALUES
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
display_name = VALUES(display_name),
@@ -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");
}
}
@@ -0,0 +1,43 @@
package com.baiyue.auth.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class AuthLoginIdentifiersSqlContractTest {
@Test
void legacyMigrationBackfillsOnlyUniquelyOwnedPhonesAndKeepsDuplicatesUnbound() throws Exception {
assertSafeBackfill(new ClassPathResource("db/auth-login-identifiers.sql")
.getContentAsString(StandardCharsets.UTF_8));
}
@Test
void emptyDatabaseInitializationDeclaresPhoneUniquenessWithoutLegacyBackfill() throws Exception {
String sql = Files.readString(unifiedSql(), StandardCharsets.UTF_8);
assertThat(sql)
.contains("phone varchar(32) DEFAULT NULL")
.contains("UNIQUE KEY uk_auth_account_phone (phone)")
.doesNotContain("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
.doesNotContain("HAVING COUNT(DISTINCT auth_user_id) = 1")
.doesNotContain("ALTER TABLE auth_account");
}
private static void assertSafeBackfill(String sql) {
assertThat(sql)
.contains("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
.contains("HAVING COUNT(DISTINCT auth_user_id) = 1")
.contains("ADD UNIQUE KEY uk_auth_account_phone (phone)")
.doesNotContain("IAM 存在重复手机号,禁止建立 Auth 唯一登录索引");
}
private static Path unifiedSql() {
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class InitialAccountSeedSqlContractTest {
@Test
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
void initializesOnlyThePlatformAdministratorAccount() throws Exception {
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
.getContentAsString(StandardCharsets.UTF_8));
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
@@ -19,11 +19,12 @@ class InitialAccountSeedSqlContractTest {
private static void assertAccountSeed(String sql) {
assertThat(sql)
.contains("(1000001, 'platform_admin'")
.contains("'平台系统管理员', 'ACTIVE'")
.contains("(1000002, 'merchant_admin'")
.contains("'IT 测试客户管理员', 'ACTIVE'")
.containsPattern("\\(1000001\\s*,\\s*'platform_admin'")
.containsPattern("'平台系统管理员'\\s*,\\s*'ACTIVE'")
.contains("password_change_required")
.doesNotContain("merchant_admin")
.doesNotContain("IT_TEST")
.doesNotContain("(1000002, 'merchant_admin'")
.doesNotContain("一期演示企业管理员");
}
@@ -0,0 +1,48 @@
package com.baiyue.auth.repository;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.dto.MerchantRegisterRequest;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class LoginIdentifierContractTest {
@Test
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
.map(component -> component.getName())
.toList();
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
}
@Test
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var invalidRegistration = new MerchantRegisterRequest(
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
"11111111111", null, "CN", null);
var validChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "13800138000", "Password1");
var invalidChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "11111111111", "Password1");
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
assertThat(validator.validate(validChange)).isEmpty();
assertThat(validator.validate(invalidChange)).isNotEmpty();
}
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("REGEXP '^1[3-9][0-9]{9}$'")
.doesNotContain("REGEXP '^1[0-9]{10}$'");
}
}
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
@@ -157,6 +159,44 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L);
}
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
var result = service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
assertEquals("new.user", result.username());
assertEquals("13900139000", result.phone());
assertEquals(3, account.getTokenVersion());
verify(sessions).revokeAll(10L);
}
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
account.setPhone("13800138000");
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
assertEquals(null, account.getPhone());
assertEquals(3, account.getTokenVersion());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
@@ -23,7 +23,10 @@ import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -34,6 +37,7 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
@@ -59,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
"WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account);
@@ -67,8 +71,11 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900,
snapshot.getRoles(), snapshot.getPermissions()));
service.login(new LoginRequest("operator", "secret"));
var response = service.login(new LoginRequest("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
@@ -79,15 +86,121 @@ class AuthServiceSmallJwtTest {
.containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactly("platform:order:list");
}
@Test
void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
account.setPhone("13800138000");
account.setPasswordHash("hash");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(0L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(84L);
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of(), Set.of(), 1L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByPhone("13800138000")).thenReturn(account);
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
service.login(new LoginRequest("13800138000", "secret"));
verify(repository).findByPhone("13800138000");
verify(repository, never()).findByUsername("13800138000");
}
@Test
void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() {
when(repository.findByUsername("11111111111")).thenReturn(null);
assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret")))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("登录标识或密码错误");
verify(repository).findByUsername("11111111111");
verify(repository, never()).findByPhone("11111111111");
}
@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,
snapshot.getRoles(), snapshot.getPermissions()));
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);
assertThat(response.roles()).containsExactly("MERCHANT_ADMIN");
assertThat(response.authorities()).containsExactly("merchant:order:list");
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
@@ -102,7 +215,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
@@ -112,8 +225,16 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900,
Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")));
service.exchangeImpersonationTicket("one-time-code");
AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
assertThat(response.authorities()).containsExactly("merchant:order:list");
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
@@ -126,5 +247,6 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
verify(overseasTokenService).issue(account, snapshot);
}
}
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
@SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@@ -35,7 +36,7 @@ class MerchantRegistrationServiceTest {
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", "张三",
@@ -54,15 +55,61 @@ class MerchantRegistrationServiceTest {
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("13800138000", storedAccount.getValue().getPhone());
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus());
}
@Test
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
account.setPasswordHash("encoded-password");
account.setAccountStatus("ACTIVE");
account.setDefaultAppCode("MERCHANT");
when(accounts.findByUsername("registered_owner")).thenReturn(account);
when(accounts.findByPhone("13800138000")).thenReturn(account);
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
assertEquals(9001L, result.accountId());
assertEquals(10001L, result.tenantId());
assertEquals("READY", result.dimensionInitializationStatus());
verify(onboarding, never()).register(eq("internal-token"), any());
verify(accounts, never()).insert(any());
}
@Test
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
when(accounts.findByPhone("13800138000")).thenReturn(account);
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.register(new MerchantRegisterRequest(
"another_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
verify(onboarding, never()).register(eq("internal-token"), any());
}
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
@@ -82,7 +129,7 @@ class MerchantRegistrationServiceTest {
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
@@ -0,0 +1,77 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class OverseasSessionValidationServiceTest {
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
private AuthAccount account;
@BeforeEach
void setUp() throws Exception {
inject("accounts", accounts);
inject("authorizationLoader", authorizationLoader);
account = new AuthAccount();
account.setId(42L);
account.setAccountStatus("ACTIVE");
account.setTokenVersion(8);
when(accounts.findById(42L)).thenReturn(account);
}
@Test
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
assertThat(service.validate(new Request(42L, 7, 900L)))
.extracting("valid", "failureReason")
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
}
@Test
void missingOrChangedWarehouseBindingFailsClosed() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
}
@Test
void disabledAccountFailsClosedBeforeIamLookup() {
account.setAccountStatus("DISABLED");
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
.isEqualTo(FailureReason.ACCOUNT_INVALID);
}
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
}
private void inject(String fieldName, Object value) throws Exception {
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(service, value);
}
}
@@ -24,6 +24,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@@ -45,10 +46,18 @@ class OverseasTokenServiceTest {
@Test
void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount();
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
String authority = "platform:overseas-master-data:quote:list";
String merchantAuthority = "merchant:warehouse:delivery:list";
String merchantCrossBorderAuthority = "merchant:cross-border:procurement";
String lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority,
financeAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -64,11 +73,67 @@ class OverseasTokenServiceTest {
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
.containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
.containsEntry("membership_id", 84L)
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L)
.containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
"permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority,
merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority);
}
@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("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 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("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 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);
}
@Test
void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable {
AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
assertThat(event.success()).isFalse();
assertThat(event.errorCode()).isEqualTo("IllegalStateException");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.afterData()).isNull();
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, null);
}
private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, new IllegalStateException("validation failed"));
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable {
AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink);
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
when(joinPoint.proceed()).thenReturn("ok");
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
else when(joinPoint.proceed()).thenThrow(failure);
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
try {
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
} catch (Throwable error) {
if (failure == null) throw error;
}
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture());
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
}
@Test
void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024);
String sanitized = sanitizer.sanitize(Map.of(
"profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"),
"contacts", java.util.List.of(Map.of("emailAddress", "user@example.com"))));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******");
assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******");
assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
@@ -0,0 +1,56 @@
package com.baiyue.common.core.container;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Platform-wide container types and practical loading limits. */
public final class ContainerSpecificationCatalog {
public static final String DEFAULT_CONTAINER_TYPE = "40HQ";
private static final List<Specification> ORDERED_VALUES = List.of(
new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"),
"美线电商较少使用,通常优先选择40HQ"),
new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"),
"轻泡货首选,适合家居、玩具、日用品"),
new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"),
"抛货量大时考虑,底盘和海外仓预约资源较少")
);
private static final Map<String, Specification> VALUES = ORDERED_VALUES.stream()
.collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity()));
private ContainerSpecificationCatalog() {}
public static List<Specification> values() {
return ORDERED_VALUES;
}
public static Specification require(String containerType) {
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
Specification specification = VALUES.get(normalized);
if (specification == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ");
}
return specification;
}
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
Specification specification = require(containerType);
if (capacityCbm == null || maxWeightKg == null
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"柜型容量和货重上限必须使用平台统一定义");
}
return specification;
}
public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm,
BigDecimal maxWeightKg, String description) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.common.core.container;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerSpecificationCatalogTest {
@Test
void exposesTheThreeCanonicalContainerTypesInBusinessOrder() {
assertThat(ContainerSpecificationCatalog.values())
.extracting(ContainerSpecificationCatalog.Specification::containerType)
.containsExactly("40GP", "40HQ", "45HQ");
assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ");
}
@Test
void exposesPracticalLoadingRangesAndRejectsRemovedTypes() {
var specification = ContainerSpecificationCatalog.requireMatch(
"40hq", new BigDecimal("68.000"), new BigDecimal("19500.000"));
assertThat(specification.containerType()).isEqualTo("40HQ");
assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65");
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC"))
.isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ");
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
"40HQ", new BigDecimal("76"), new BigDecimal("19500")))
.isInstanceOf(BizException.class).hasMessageContaining("统一定义");
}
}
+15 -4
View File
@@ -14,12 +14,23 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.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/`、国内生产者以及固定 BOM 版本的尾程消费者。
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
- 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -0,0 +1,61 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/**
* 跨区域业务费用来源的完整不可变快照。
*
* <p>海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。
* 标识统一使用字符串,避免跨语言大整数精度损失。</p>
*/
public record BusinessFeeSourceChangedV1(
String action,
String sourceSystem,
String sourceType,
String sourceFeeId,
int sourceVersion,
String customerId,
String customerName,
String sourceNo,
String businessType,
String businessId,
String businessNo,
String orderId,
String inboundId,
String inboundCode,
String containerId,
String containerBookingId,
String containerNo,
String warehouseId,
String feeItemCode,
String feeItemName,
String direction,
String supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
Instant paidAt,
String paymentMethod,
String paymentReferenceNo,
List<String> paymentVoucherFileIds,
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String originalCurrency,
BigDecimal originalAmount,
BigDecimal exchangeRate,
String exchangeRateMonth,
BigDecimal chargeableQuantity,
String quantityUnit,
BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
String reviewStatus,
String publicationStatus,
String sourceStatus,
String sourceHash
) {
}
@@ -0,0 +1,44 @@
package com.baiyue.common.event;
import java.util.Locale;
/** Canonical physical container number used by cross-region business facts. */
public final class ContainerNumbers {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private ContainerNumbers() { }
public static String normalizeRequired(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
StringBuilder compact = new StringBuilder(value.length());
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
String normalized = compact.toString().toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = normalized.charAt(index);
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
}
return normalized;
}
public static String requireCanonical(String value) {
String normalized = normalizeRequired(value);
if (!normalized.equals(value)) {
throw new IllegalArgumentException("containerNo must be normalized");
}
return normalized;
}
}
@@ -10,6 +10,14 @@ public final class EventTypes {
public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted";
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested";
public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -0,0 +1,52 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String oceanBookingId,
@JsonProperty("sourceContainerBookingId") 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()) {
throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
}
if (oceanBookingId != null && oceanBookingId.isBlank()) {
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
}
if (containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
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("船节点交接供应商审计字段不完整");
}
}
}
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
@@ -16,9 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType, String lxCode) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -28,11 +43,41 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, 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, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType, 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,
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
}
}
@@ -0,0 +1,176 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV2 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
Order owner = orders.get(0);
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|| !owner.customerName().equals(order.customerName()))) {
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
}
}
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, 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,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType, String lxCode) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
customerId = customerId.trim();
customerName = customerName.trim();
if (customerId.length() > 128 || customerName.length() > 128) {
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
}
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 customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, 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, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType, 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,
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, 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());
}
}
/** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
Integer manifestVersion, Integer manifestPackageCount,
Integer manifestFragmentCount, String manifestHash) {
public PalletSnapshot {
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
}
public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
this(palletNo, skuPackages, null, null, null, null);
}
}
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks, java.util.Map<String, Integer> cartonQuantities) {
public SkuPackageSnapshot {
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
}
if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount
|| new java.util.HashSet<>(cartonMarks).size() != packageCount
|| !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks))
|| cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0)
|| cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) {
throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity");
}
cartonMarks = List.copyOf(cartonMarks);
cartonQuantities = java.util.Map.copyOf(cartonQuantities);
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** 装柜完成 V3:在 V2 货物快照上增加整柜五类国内装柜照片引用。 */
public record FirstLegLoadingPlanCompletedV3(
String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<FirstLegLoadingPlanCompletedV2.Order> orders, List<LoadingEvidence> loadingEvidence) {
public FirstLegLoadingPlanCompletedV3 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
loadingEvidence = loadingEvidence == null ? List.of() : List.copyOf(loadingEvidence);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
var owner = orders.get(0);
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|| !owner.customerName().equals(order.customerName()))) {
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
}
if (loadingEvidence.size() != 5
|| loadingEvidence.stream().map(LoadingEvidence::evidenceType).distinct().count() != 5) {
throw new IllegalArgumentException("five unique loading evidence types are required");
}
}
public record LoadingEvidence(String evidenceType, long fileId, String fileName) {
public LoadingEvidence {
if (evidenceType == null || !evidenceType.matches("PHOTO_STEP_[1-4]|SEAL")) {
throw new IllegalArgumentException("unsupported loading evidence type");
}
if (fileId <= 0) throw new IllegalArgumentException("loading evidence fileId must be positive");
if (fileName == null || fileName.isBlank() || fileName.length() > 255) {
throw new IllegalArgumentException("loading evidence fileName is required");
}
fileName = fileName.trim();
}
}
}
@@ -1,6 +1,7 @@
package com.baiyue.common.event;
import java.time.Instant;
import java.util.Set;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 {
@@ -14,5 +15,11 @@ public final class IamSecurityEventsV1 {
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
long permissionVersion, String reason, Instant changedAt) { }
long permissionVersion, String reason, Instant changedAt,
Set<String> roles, Set<String> authorities) {
public PermissionVersionChanged {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
}
@@ -0,0 +1,22 @@
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
) {
public OverseasContainerUnloadedV1 {
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
throw new IllegalArgumentException("sourceContainerBookingId is required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
}
}
@@ -0,0 +1,19 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外派送单按目的 FBA 仓签收后,回传对应国内入仓单和订单的完成事实。 */
public record OverseasDeliveryCompletedV1(
String deliveryOrderId,
String deliveryNo,
String lastlegOrderId,
String lastlegOrderNo,
String sourceInboundId,
String inboundCode,
String sourceOrderId,
String sourceOrderNo,
String rootOrderId,
String fbaWarehouseCode,
Instant completedAt
) {
}
@@ -0,0 +1,17 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
public record OverseasInboundOrderDetainedChangedV1(
String sourceInboundId,
String inboundCode,
String lastlegOrderId,
String lastlegOrderNo,
boolean detained,
String reason,
String operatorId,
String operatorName,
Instant changedAt
) {
}
@@ -0,0 +1,21 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
/** 国内 Finance 对海外资金指令的最终、可幂等投影结果。 */
public record WalletCommandCompletedV1(
String commandEventId,
String operation,
long customerId,
String businessType,
String businessNo,
String result,
String holdStatus,
BigDecimal amount,
String errorCode,
String errorMessage,
Instant completedAt
) {
public static final int VERSION = 1;
}
@@ -0,0 +1,18 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */
public record WalletCommandRequestedV1(
String operation,
long customerId,
String businessType,
String businessNo,
BigDecimal amount,
String remark,
String idempotencyKey,
Instant requestedAt
) {
public static final int VERSION = 1;
}
@@ -0,0 +1,108 @@
openapi: 3.1.0
info:
title: Baiyue Cross-region Event Contracts
version: 1.0.0
paths: {}
components:
schemas:
FirstLegLoadingPlanCompletedV3:
allOf:
- $ref: '#/components/schemas/FirstLegLoadingPlanCompletedV2'
- type: object
required: [loadingEvidence]
properties:
loadingEvidence:
type: array
minItems: 5
maxItems: 5
description: Exactly one active domestic loading photo reference for each of PHOTO_STEP_1 through PHOTO_STEP_4 and SEAL.
items: {$ref: '#/components/schemas/FirstLegLoadingEvidenceV3'}
FirstLegLoadingEvidenceV3:
type: object
required: [evidenceType, fileId, fileName]
properties:
evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]}
fileId: {type: integer, format: int64, minimum: 1, description: Domestic File service identity; never exposed to PDA clients}
fileName: {type: string, minLength: 1, maxLength: 255}
FirstLegLoadingPlanCompletedV2:
type: object
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
properties:
loadingPlanId: {type: string}
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
cabinetNo: {type: string}
warehouseId: {type: string}
warehouseName: {type: string}
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
orders:
type: array
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
FirstLegLoadingPlanCompletedOrderV2:
type: object
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
properties:
sourceInboundId: {type: string}
sourceOrderId: {type: string}
sourceOrderNo: {type: string}
inboundCode: {type: string}
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
packageCount: {type: integer, minimum: 1}
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
items: {type: array, items: {type: object}}
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
pallets: {type: array, items: {$ref: '#/components/schemas/FirstLegPalletSnapshotV2'}}
destinationType: {type: string, enum: [FBA, OVERSEAS]}
FirstLegContainerShippingNodeRecordedV1:
type: object
required: [sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
properties:
queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.}
oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.}
sourceContainerBookingId: {type: string, minLength: 1}
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
standardNode: {type: string}
nodeSequence: {type: integer}
observedAt: {type: string, format: date-time}
actualAt: {type: string, format: date-time}
providerCode: {type: string}
providerEventId: {type: string}
providerNodeCode: {type: string}
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
FirstLegPalletSnapshotV2:
type: object
required: [palletNo, skuPackages]
properties:
palletNo: {type: string, minLength: 1}
skuPackages:
type: array
items: {$ref: '#/components/schemas/FirstLegSkuPackageSnapshotV2'}
FirstLegSkuPackageSnapshotV2:
type: object
required: [sourceInboundDetailId, skuCode, productName, packageCount, productQuantity, cartonMarks, cartonQuantities]
properties:
sourceInboundDetailId: {type: string, minLength: 1, description: Exact source inbound detail identity matching the order item; never infer from SKU because multiple details may share a SKU}
skuCode: {type: string}
productName: {type: [string, 'null']}
packageCount: {type: integer, minimum: 1}
productQuantity: {type: integer, minimum: 1}
cartonMarks: {type: array, items: {type: string}, minItems: 1}
cartonQuantities:
type: object
description: 箱唛到逐箱实收件数的完整映射;键与 cartonMarks 相同,件数合计等于 productQuantity
additionalProperties: {type: integer, minimum: 1}
OverseasContainerUnloadedV1:
type: object
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
properties:
sourceContainerBookingId: {type: string, minLength: 1}
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
unloadingCompletedAt: {type: string, format: date-time}
unloadingPhotoUploadedAt: {type: string, format: date-time}
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
@@ -0,0 +1,20 @@
package com.baiyue.common.event;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerNumbersTest {
@Test
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
.hasMessageContaining("normalized");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
.hasMessageContaining("check digit");
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
.hasMessageContaining("ISO 6346");
}
}
@@ -0,0 +1,74 @@
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);
var json = mapper.valueToTree(payload);
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
assertThat(json.has("containerBookingId")).isFalse();
}
@Test
void rejectsNonCanonicalPhysicalContainerNumber() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
.hasMessageContaining("normalized");
}
@Test
void acceptsDirectManualNodeWithoutTrackingQuery() {
var payload = payload();
var manual = new FirstLegContainerShippingNodeRecordedV1(
null, null, payload.containerBookingId(), payload.containerNo(),
payload.standardNode(), payload.nodeSequence(), payload.observedAt(), payload.actualAt(),
payload.locationCode(), payload.locationName(), "MANUAL", "manual-event", payload.providerNodeCode(),
"MANUAL:7", payload.rawPayloadHash());
assertThat(manual.queryId()).isNull();
assertThat(manual.oceanBookingId()).isNull();
}
@Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "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", "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", "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));
}
}
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
new BigDecimal("0.200"), List.of(item));
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -47,4 +47,25 @@ class FirstLegLoadingPlanCompletedV1Test {
assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
}
@Test
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
assertThat(restored.actualPortArrivalAt()).isNull();
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
}
}
@@ -0,0 +1,184 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void exactCartonQuantitiesRejectMissingMarksAndIncorrectTotals() {
var marks = List.of("BOX-A", "BOX-B");
var snapshot = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 13));
assertThat(snapshot.cartonQuantities().get("BOX-B")).isEqualTo(13);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 20))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 7, "BOX-B", 12))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SKU", "产品", 2, 20,
marks, java.util.Map.of("BOX-A", 0, "BOX-B", 20))).isInstanceOf(IllegalArgumentException.class);
}
@Test
void palletSkuRequiresExplicitSourceDetailIncludingJsonDeserialization() throws Exception {
var mapper = new ObjectMapper();
var first = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("101", "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10));
var second = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot("102", "SAME", "产品", 1, 20, List.of("BOX-102"), java.util.Map.of("BOX-102", 20));
assertThat(mapper.readValue(mapper.writeValueAsString(first), FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class))
.isEqualTo(first).isNotEqualTo(second);
for (String invalid : new String[]{null, "", " "}) {
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(invalid, "SAME", "产品", 1, 10, List.of("BOX-101"), java.util.Map.of("BOX-101", 10)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("sourceInboundDetailId");
}
assertThatThrownBy(() -> mapper.readValue("{\"skuCode\":\"SAME\",\"packageCount\":1,\"productQuantity\":10,\"cartonMarks\":[\"BOX-101\"]}",
FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot.class)).hasMessageContaining("sourceInboundDetailId");
}
@Test
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
assertThat(json.has("containerBookingId")).isFalse();
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
}
@Test
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("normalized");
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("containerNo");
}
@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),
"11", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "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(
"101", "SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"), java.util.Map.of("BOX-001", 7, "BOX-002", 13));
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", "ZCSU7881970", "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/customerName").asText()).isEqualTo("客户甲");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()).isEqualTo("101");
}
@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", "ZCSU7881970", "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),
"11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "ZCSU7881970", "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);
}
@Test
void rejectsMissingImmutableCustomerName() {
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
}
@Test
void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() {
var first = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of());
assertThat(first.customerId()).isEqualTo("11");
assertThat(first.customerName()).isEqualTo("客户甲");
var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order(
"3", "3", "4", "ORD-2", "4", "RK-2", null, null, null,
1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of());
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName");
}
@Test
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
String yaml;
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
"/openapi/cross-region-events-v1.yaml")) {
assertThat(input).isNotNull();
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(yaml)
.contains("required: [sourceInboundId, customerId, customerName")
.contains("customerId: {type: string, minLength: 1")
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
.contains("consumers must not query domestic IAM")
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
}
}
@@ -0,0 +1,46 @@
package com.baiyue.common.event;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.jupiter.api.Test;
class FirstLegLoadingPlanCompletedV3Test {
@Test
void carriesExactlyFiveDomesticLoadingPhotoReferences() {
var json = new ObjectMapper().valueToTree(payload(evidence()));
assertThat(json.path("loadingEvidence")).hasSize(5);
assertThat(json.at("/loadingEvidence/4/evidenceType").asText()).isEqualTo("SEAL");
}
@Test
void rejectsMissingOrDuplicatePhotoTypes() {
assertThatThrownBy(() -> payload(evidence().subList(0, 4)))
.hasMessageContaining("five unique");
assertThatThrownBy(() -> payload(List.of(
photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_1", 2), photo("PHOTO_STEP_2", 3),
photo("PHOTO_STEP_3", 4), photo("SEAL", 5))))
.hasMessageContaining("five unique");
}
private FirstLegLoadingPlanCompletedV3 payload(List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> photos) {
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");
return new FirstLegLoadingPlanCompletedV3(
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
null, null, null, List.of(order), photos);
}
private List<FirstLegLoadingPlanCompletedV3.LoadingEvidence> evidence() {
return List.of(photo("PHOTO_STEP_1", 1), photo("PHOTO_STEP_2", 2),
photo("PHOTO_STEP_3", 3), photo("PHOTO_STEP_4", 4), photo("SEAL", 5));
}
private FirstLegLoadingPlanCompletedV3.LoadingEvidence photo(String type, long id) {
return new FirstLegLoadingPlanCompletedV3.LoadingEvidence(type, id, type + ".jpg");
}
}
@@ -0,0 +1,34 @@
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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
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", "ZCSU7881970", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
}
@Test
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
.hasMessageContaining("normalized");
}
}
@@ -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 OverseasDeliveryCompletedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
void roundTripsStableSourceIdentifiers() throws Exception {
var value = new OverseasDeliveryCompletedV1("31", "DL-31", "41", "LM-41", "51",
"NYEXX2609080022", "61", "FL-61", "61", "ACY2",
Instant.parse("2026-09-15T08:00:00Z"));
var restored = mapper.readValue(mapper.writeValueAsBytes(value), OverseasDeliveryCompletedV1.class);
assertThat(restored).isEqualTo(value);
assertThat(restored.sourceInboundId()).isEqualTo("51");
assertThat(restored.sourceOrderId()).isEqualTo("61");
}
}
+3 -1
View File
@@ -13,11 +13,13 @@
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` |
## 修改与验证
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
.addOperationCustomizer(correlationHeaderCustomizer).build();
}
@Bean
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
return GroupedOpenApi.builder().group("pda")
.displayName("PDA APIs / PDA 作业接口")
.pathsToMatch("/api/pda/**")
.addOperationCustomizer(correlationHeaderCustomizer).build();
}
private Parameter header(String name, String description, boolean required) {
return new HeaderParameter().name(name).description(description).required(required)
.schema(new io.swagger.v3.oas.models.media.StringSchema());
@@ -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"));
}
}
+7 -4
View File
@@ -9,17 +9,20 @@
| 功能 | 入口 |
|---|---|
| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` |
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `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/部署配置和所有实际业务服务。
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
@@ -1,6 +1,7 @@
package com.baiyue.common.security;
import org.springframework.security.oauth2.jwt.Jwt;
import jakarta.servlet.http.HttpServletRequest;
/**
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
@@ -8,4 +9,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
@FunctionalInterface
public interface AccessTokenSessionValidator {
AccessTokenSession validate(Jwt jwt);
default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
return validate(jwt);
}
}
@@ -7,13 +7,36 @@ import java.util.Set;
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
*/
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
Long actorMembershipId, boolean passwordChangeRequired,
Long overseasWarehouseId) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, false, null, null, false);
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, false, null, null, false, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String displayName, String appCode, String clientType,
Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) {
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
}
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions,
boolean impersonated, Long actorUserId, Long actorMembershipId,
boolean passwordChangeRequired, Long overseasWarehouseId) {
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
overseasWarehouseId);
}
public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
@@ -50,9 +50,11 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
else {
StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
@@ -0,0 +1,65 @@
package com.baiyue.common.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.LinkedHashSet;
import java.util.Set;
/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */
public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator {
public static final String TOKEN_HEADER = "X-Internal-Service-Token";
public static final String ROLES_HEADER = "X-Overseas-Roles";
public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities";
private final byte[] expectedToken;
public GatewayAccessTokenSessionValidator(String expectedToken) {
if (expectedToken == null || expectedToken.isBlank()) {
throw new IllegalStateException("session-mode=gateway requires an internal service token");
}
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
}
@Override
public AccessTokenSession validate(Jwt jwt) {
throw new IllegalArgumentException("Gateway authorization headers are required");
}
@Override
public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
String supplied = request.getHeader(TOKEN_HEADER);
if (supplied == null || supplied.isBlank()) {
throw new IllegalArgumentException("Missing overseas gateway authorization context");
}
byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, suppliedToken)) {
throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes="
+ expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")");
}
Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id")));
if (userId == null) {
userId = number(jwt.getSubject());
}
return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)),
values(request.getHeader(AUTHORITIES_HEADER)));
}
private static Long number(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException exception) { return null; }
}
private static Set<String> values(String header) {
if (header == null || header.isBlank()) return Set.of();
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String value : header.split(",")) {
String normalized = value.trim();
if (!normalized.isEmpty()) values.add(normalized);
}
return Set.copyOf(values);
}
}
@@ -12,6 +12,8 @@ import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.web.filter.OncePerRequestFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
@@ -19,6 +21,7 @@ import java.io.IOException;
* Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger LOG = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER = "Bearer ";
private final JwtDecoder jwtDecoder;
private final JwtClaimsMapper claimsMapper;
@@ -55,11 +58,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenValue = header.substring(BEARER.length()).trim();
try {
Jwt jwt = jwtDecoder.decode(tokenValue);
AccessTokenSession session = sessionValidator.validate(jwt);
AccessTokenSession session = sessionValidator.validate(jwt, request);
Authentication authentication = claimsMapper.map(jwt, session);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (JwtException | IllegalArgumentException exception) {
LOG.warn("JWT authentication rejected for {}: {}", request.getRequestURI(), exception.getMessage());
SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response,
new InvalidJwtAuthenticationException("Invalid or expired access token", exception));
@@ -37,13 +37,15 @@ public class JwtClaimsMapper {
longClaim(jwt, "operator_tenant_id"),
longClaim(jwt, "membership_id"),
username,
firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")),
appCode,
jwt.getClaimAsString("client_type"),
permissions,
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
longClaim(jwt, "actor_user_id"),
longClaim(jwt, "actor_membership_id"),
Boolean.TRUE.equals(jwt.getClaim("password_change_required")));
Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
longClaim(jwt, "overseas_warehouse_id"));
List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -86,4 +88,9 @@ public class JwtClaimsMapper {
for (String value : values) if (value != null && !value.isBlank()) return value;
return "unknown";
}
private String firstNonBlankNullable(String... values) {
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
return null;
}
}
@@ -0,0 +1,49 @@
package com.baiyue.common.security;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GatewayAccessTokenSessionValidatorTest {
private final GatewayAccessTokenSessionValidator validator =
new GatewayAccessTokenSessionValidator("gateway-secret");
@Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view");
AccessTokenSession session = validator.validate(jwt(), request);
assertThat(session.userId()).isEqualTo(42L);
assertThat(session.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(session.permissions()).containsExactlyInAnyOrder(
"platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view");
}
@Test void rejectsForgedOrMissingGatewayToken() {
MockHttpServletRequest forged = new MockHttpServletRequest();
forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged");
forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list");
assertThatThrownBy(() -> validator.validate(jwt(), forged))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway");
assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway");
}
private Jwt jwt() {
return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permission_version", 7L).claim("token_version", 1).build();
}
}
@@ -41,4 +41,34 @@ class JwtAuthenticationFilterTest {
assertThat(invoked).isTrue();
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
}
@Test
void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception {
Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permission_version", 7L).claim("token_version", 1).build();
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(),
new JsonAuthenticationEntryPoint(new ObjectMapper()),
new GatewayAccessTokenSessionValidator("gateway-secret"));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/platform/v1/last-leg/orders");
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean invoked = new AtomicBoolean();
filter.doFilter(request, response, (request1, response1) -> {
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertThat(authentication).isNotNull();
assertThat(authentication.getAuthorities()).extracting("authority")
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view");
invoked.set(true);
});
assertThat(invoked).isTrue();
assertThat(response.getStatus()).isEqualTo(200);
}
}
@@ -81,6 +81,7 @@ class JwtClaimsMapperTest {
.claim("actor_user_id", 42L)
.claim("actor_membership_id", 43L)
.claim("password_change_required", true)
.claim("overseas_warehouse_id", 900L)
.build();
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
@@ -88,5 +89,6 @@ class JwtClaimsMapperTest {
assertThat(user.actorUserId()).isEqualTo(42L);
assertThat(user.actorMembershipId()).isEqualTo(43L);
assertThat(user.passwordChangeRequired()).isTrue();
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
}
}
+7 -1
View File
@@ -11,6 +11,9 @@
|---|---|
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
@@ -22,6 +25,9 @@
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。
- 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+9
View File
@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
@@ -39,6 +43,11 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
public final class CorrelationContext {
public static final String REQUEST_ID = "requestId";
public static final String TRACE_ID = "traceId";
public static final String SPAN_ID = "spanId";
public static final String TRACE_PARENT = "traceparent";
public static final String REQUEST_ID_HEADER = "X-Request-Id";
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
private CorrelationContext() {}
@@ -22,6 +25,14 @@ public final class CorrelationContext {
return value(request, TRACE_ID);
}
public static String spanId(HttpServletRequest request) {
return value(request, SPAN_ID);
}
public static String traceParent(HttpServletRequest request) {
return value(request, TRACE_PARENT);
}
private static String value(HttpServletRequest request, String key) {
Object attribute = request.getAttribute(key);
return attribute == null ? MDC.get(key) : attribute.toString();
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Enumeration;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false);
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true);
String requestId = requestId(request);
TraceParent traceParent = traceParent(request);
String traceId = traceParent.traceId();
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) {
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
filterChain.doFilter(request, response);
}
}
private String acceptedOrGenerated(String candidate, boolean compact) {
if (candidate != null && SAFE_ID.matcher(candidate).matches()) {
return candidate;
private String requestId(HttpServletRequest request) {
HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
return header.value();
}
return UUID.randomUUID().toString();
}
private TraceParent traceParent(HttpServletRequest request) {
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
TraceParent.HEADER_LENGTH);
if (standard.present()) {
if (standard.valid()) {
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
.orElseGet(() -> TraceParent.newRoot(null));
}
return TraceParent.newRoot(null);
}
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
}
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
Enumeration<String> values = request.getHeaders(name);
if (values == null || !values.hasMoreElements()) {
return HeaderValue.missing();
}
String value = values.nextElement();
boolean duplicate = values.hasMoreElements();
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
}
private record HeaderValue(boolean present, boolean valid, String value) {
private static HeaderValue missing() {
return new HeaderValue(false, false, null);
}
String id = UUID.randomUUID().toString();
return compact ? id.replace("-", "") : id;
}
}
@@ -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);
}
}
@@ -15,12 +15,17 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -62,7 +67,8 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class})
MissingServletRequestParameterException.class, MissingServletRequestPartException.class,
MethodArgumentTypeMismatchException.class})
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
}
@@ -79,6 +85,39 @@ public class GlobalExceptionHandler {
return failure(ResultCode.FORBIDDEN, ResultCode.FORBIDDEN.message(), request);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(
ResponseStatusException exception, HttpServletRequest request) {
ResultCode code = resultCode(exception.getStatusCode().value());
String message = exception.getReason() == null || exception.getReason().isBlank()
? code.message() : exception.getReason();
return failure(code, message, request);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException exception, HttpServletRequest request) {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(CannotAcquireLockException.class)
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
CannotAcquireLockException exception, HttpServletRequest request) {
log.warn("Database lock acquisition conflict requestId={} traceId={}",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(
MaxUploadSizeExceededException exception, HttpServletRequest request) {
long maxBytes = exception.getMaxUploadSize();
String message = maxBytes > 0
? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB"
: "上传文件超过服务允许大小";
return failure(ResultCode.BAD_REQUEST, message, request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -123,4 +162,18 @@ public class GlobalExceptionHandler {
meterRegistry.counter("baiyue.api.business.failures", "code", code.code()).increment();
}
}
private ResultCode resultCode(int status) {
return switch (status) {
case 400 -> ResultCode.BAD_REQUEST;
case 401 -> ResultCode.UNAUTHORIZED;
case 403 -> ResultCode.FORBIDDEN;
case 404 -> ResultCode.NOT_FOUND;
case 409 -> ResultCode.CONFLICT;
case 422 -> ResultCode.VALIDATION_FAILED;
case 429 -> ResultCode.TOO_MANY_REQUESTS;
case 503 -> ResultCode.SERVICE_UNAVAILABLE;
default -> ResultCode.INTERNAL_ERROR;
};
}
}
@@ -0,0 +1,40 @@
package com.baiyue.common.web;
import org.slf4j.MDC;
import java.util.UUID;
import java.util.regex.Pattern;
/** Immutable standard headers for one synchronous outbound child span. */
public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) {
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
/** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */
public static OutboundTraceHeaders fromCurrentOrNew() {
String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID));
String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID))
.orElseGet(() -> TraceParent.newRoot(null).traceId());
return child(requestId, traceId);
}
/** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */
public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) {
String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId)
.orElseGet(() -> TraceParent.newRoot(null).traceId());
return child(UUID.randomUUID().toString(), traceId);
}
public String spanId() {
return TraceParent.parse(traceParent).orElseThrow().parentId();
}
private static OutboundTraceHeaders child(String requestId, String traceId) {
TraceParent child = TraceParent.outboundChild(traceId);
return new OutboundTraceHeaders(requestId, child.traceId(), child.value());
}
private static String safeRequestId(String value) {
return value != null && SAFE_REQUEST_ID.matcher(value).matches()
? value : UUID.randomUUID().toString();
}
}
@@ -0,0 +1,24 @@
package com.baiyue.common.web;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.core.Ordered;
import org.springframework.web.client.RestClient;
/** Installs the trace interceptor after ordinary RestClient customizers. */
public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered {
private final RestClientTraceInterceptor interceptor;
public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) {
this.interceptor = interceptor;
}
@Override
public void customize(RestClient.Builder builder) {
builder.requestInterceptor(interceptor);
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
@@ -0,0 +1,21 @@
package com.baiyue.common.web;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
/** Applies the trusted synchronous trace context to Spring RestClient requests. */
public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew();
request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId());
request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId());
request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent());
return execution.execute(request, body);
}
}
@@ -0,0 +1,25 @@
package com.baiyue.common.web;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestClient;
/** Adds trace propagation to Spring-managed RestClient builders. */
@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration")
@ConditionalOnClass({RestClient.class, RestClientCustomizer.class})
public class RestClientTracePropagationAutoConfiguration {
@Bean
@ConditionalOnMissingBean(RestClientTraceInterceptor.class)
RestClientTraceInterceptor restClientTraceInterceptor() {
return new RestClientTraceInterceptor();
}
@Bean
RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) {
return new RestClientTraceCustomizer(interceptor);
}
}

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