Compare commits

...
889 Commits
Author SHA1 Message Date
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
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
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
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
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
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
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
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
ouyangxq 76c24867a4 优化 2026-08-27 17:16:33 +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
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 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
wujinliang d736fdba1b 1 2026-08-26 18:12:55 +08:00
1881 changed files with 130132 additions and 34135 deletions
+1
View File
@@ -28,5 +28,6 @@ reports/
uploads/ uploads/
.codex-temp* .codex-temp*
.codex-tmp* .codex-tmp*
.runtime-*/
.codex-*.out .codex-*.out
.codex-*.err .codex-*.err
Binary file not shown.
-39
View File
@@ -1,39 +0,0 @@
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
public final class DbQuery {
public static void main(String[] args) throws Exception {
if (args.length < 2) {
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
}
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
+ "&useSSL=false&allowPublicKeyRetrieval=true";
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
Statement statement = connection.createStatement()) {
boolean hasResult = statement.execute(args[1]);
if (!hasResult) {
System.out.println("updateCount=" + statement.getUpdateCount());
return;
}
try (ResultSet result = statement.getResultSet()) {
ResultSetMetaData meta = result.getMetaData();
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(meta.getColumnLabel(column));
}
System.out.println();
while (result.next()) {
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(String.valueOf(result.getObject(column)));
}
System.out.println();
}
}
}
}
}
Binary file not shown.
+52 -21
View File
@@ -18,37 +18,57 @@
| 任务关键词 | 首选服务 | 先看入口 | | 任务关键词 | 首选服务 | 先看入口 |
|---|---|---| |---|---|---|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 客户建档默认箱规与权重系数初始化及补偿 | `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/` 定位;业务事件生产者回到所属服务 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | 客户头程订单中文批量导入 | `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`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `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` 和客户范围校验 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | | 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | | 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | | 平台操作日志与审计查询 | `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` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `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 已移除 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `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` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 海外异常退回、库存恢复与客户回访 | `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 候选进入可收货 | | 国内船节点向海外接柜门禁事件 | `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 引用 | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `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`;只用于约束服务内部依赖,不改变服务和数据库边界 | | 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` | | 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` 消费。标签与资源预留的细节继续读对应服务指引。
## 公共模块快速索引 ## 公共模块快速索引
| 关键词 | 模块与入口 | | 关键词 | 模块与入口 |
@@ -63,20 +83,23 @@
## 全量验收路由 ## 全量验收路由
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。 - 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。 - `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同 ## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 - 统一使用 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/` 与目标消费者。 - 头程装柜完成向海外建尾程单、海外 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` 不读取尾程订单库。 - 海外浏览器请求只进入 `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。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 修改 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`。 - 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
@@ -84,24 +107,32 @@
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 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、数据库或部署环境。 - `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`。实际部署仍须当前对话明确授权。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由 ## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 - 脚本管理的唯一总索引是 `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 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--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。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 - 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/订单与客户/订单与客户头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 入库测量与计费:`../docs/入库测量与计费/README.md`、`../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 - 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/国内货柜履约开工门禁.md`、`../docs/国内货柜履约/国内货柜履约开发规划.md`、`../docs/国内货柜履约/国内货柜履约需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+81 -34
View File
@@ -60,14 +60,15 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:] def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) { def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS' if (!description?.startsWith('AUTO ')) return null
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null int markerIndex = description.indexOf(' | ')
int markerIndex = description.indexOf(marker) if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim() String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim() String state = description.substring(markerIndex + ' | '.length()).trim()
List<String> failedServices = [] List<String> failedServices = []
if (summary) { if (state.startsWith('RESULTS')) {
String summary = state.substring('RESULTS'.length()).trim()
summary.split(',').collect { it.trim() }.findAll { it }.each { item -> summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=') int separator = item.indexOf('=')
if (separator > 0) { if (separator > 0) {
@@ -76,6 +77,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service 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()] return [commit: commit, failedServices: failedServices.unique()]
} }
@@ -85,7 +97,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
int inspected = 0 int inspected = 0
while (candidate != null && inspected < 30) { while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices) def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state // 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
if (state != null) return state
candidate = candidate.previousBuild candidate = candidate.previousBuild
inspected++ inspected++
} }
@@ -93,15 +106,37 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
} }
def environmentChoicesForJob(String jobBaseName) { def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev' return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
? ['development', 'test', 'staging', 'production']
: ['test', 'development', 'staging', 'production']
} }
def downstreamJobPrefixForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev' switch (jobBaseName) {
? 'baiyue-firstleg-dev-' case 'backend-java-dev':
: 'baiyue-firstleg-' 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 { pipeline {
@@ -135,9 +170,9 @@ pipeline {
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
} }
stages { stages {
@@ -145,14 +180,29 @@ pipeline {
agent { label 'java-builder' } agent { label 'java-builder' }
steps { steps {
script { script {
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。 String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。 String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev' if (!resolvedEnvironment || !resolvedJobPrefix) {
? 'development' error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
: params.ENVIRONMENT }
if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}"
}
env.RESOLVED_ENVIRONMENT = resolvedEnvironment env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2
exit 1
}
'''
}
String requestedRepository = params.IMAGE_REPOSITORY?.trim() String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('必须填写真实镜像仓库地址') error('必须填写真实镜像仓库地址')
@@ -163,11 +213,7 @@ pipeline {
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}" echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
if (!jobPrefix) {
error('DOWNSTREAM_JOB_PREFIX 不能为空')
}
env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false' env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试') error('生产环境禁止跳过测试')
@@ -190,9 +236,10 @@ pipeline {
String base = autoState?.commit?.trim() String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: [] List retryServices = autoState?.failedServices ?: []
if (!base) { if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录, // 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// SELECT/ALL 不会推进 AUTO 基线。 // 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() 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) { if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules selected = serviceModules
@@ -269,14 +316,13 @@ pipeline {
} }
} }
stage('生产环境部署确认(仅 production 执行)') { stage('Gateway 路由静态门禁') {
when { when {
allOf { expression { env.NO_SERVICE_CHANGE != 'true' }
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
} }
agent { label 'java-builder' }
steps { steps {
input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署' sh 'node --test scripts/gateway-route-contract.test.mjs'
} }
} }
@@ -295,6 +341,7 @@ pipeline {
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """ sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" 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 mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
""" """
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
+66 -34
View File
@@ -21,6 +21,23 @@ def serviceJars = [
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
] ]
def fixedEnvironmentForServiceJob(String jobBaseName) {
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 { pipeline {
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。 // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent none agent none
@@ -35,12 +52,12 @@ pipeline {
parameters { parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
} }
environment { environment {
@@ -65,6 +82,7 @@ pipeline {
String sourceCommit = params.SOURCE_COMMIT?.trim() String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim() String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim() String imageRepository = params.IMAGE_REPOSITORY?.trim()
String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME)
if (!sourceCommit) { if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
@@ -76,6 +94,12 @@ pipeline {
if (!serviceModules.contains(service)) { if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
} }
if (!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' && boolean deployBothBridges = service == 'event-bridge-service' &&
imageTag?.endsWith('-both') imageTag?.endsWith('-both')
if (deployBothBridges) { if (deployBothBridges) {
@@ -95,6 +119,7 @@ pipeline {
} }
env.RESOLVED_SERVICE = service env.RESOLVED_SERVICE = service
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository env.RESOLVED_IMAGE_REPOSITORY = imageRepository
@@ -106,7 +131,7 @@ pipeline {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
} }
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
!['development', 'test'].contains(params.ENVIRONMENT)) { !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
} }
} }
@@ -121,8 +146,18 @@ pipeline {
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
''' '''
sh '''
if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || {
echo "生产构建只允许已经合并到 main 的提交" >&2
exit 1
}
fi
'''
script { script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}" 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}" echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
} }
} }
@@ -132,14 +167,14 @@ pipeline {
steps { steps {
script { script {
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
} }
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : '' String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) { if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (params.ENVIRONMENT == 'test') { } else if (env.RESOLVED_ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
} }
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
@@ -188,15 +223,15 @@ pipeline {
stage('部署开发或测试环境') { stage('部署开发或测试环境') {
when { when {
beforeAgent true beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) } expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
} }
agent none agent none
steps { steps {
script { script {
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot"
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
String credentialId = "firstleg-${params.ENVIRONMENT}-env" String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
lock(resource: serviceDeployResource, inversePrecedence: true) { lock(resource: serviceDeployResource, inversePrecedence: true) {
@@ -205,7 +240,7 @@ pipeline {
deleteDir() deleteDir()
try { try {
unstash 'deployment-files' unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}"
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
@@ -224,7 +259,7 @@ pipeline {
'TARGET_IS_LOCAL=true', 'TARGET_IS_LOCAL=true',
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
]) { ]) {
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" 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\""
} }
} }
} }
@@ -238,37 +273,34 @@ pipeline {
} }
} }
stage('部署单个服务') { stage('部署生产环境单个服务') {
when { when {
beforeAgent true beforeAgent true
expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) } expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' }
}
agent {
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
} }
agent { label 'java-builder' }
steps { steps {
deleteDir() deleteDir()
unstash 'deployment-files' unstash 'deployment-files'
script { script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env" String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy"
boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' && lock(resource: serviceDeployResource, inversePrecedence: true) {
env.NODE_LABELS?.tokenize()?.contains('java-dev') withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
try { sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
withEnv([ withEnv([
"DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}", '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}" "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) { ]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { 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\""
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
} }
} }
} finally {
sh "docker logout ${registryHost} || true"
}
} }
} }
} }
+17 -1
View File
@@ -27,12 +27,28 @@ mvn test
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 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` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: 本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
+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` 产生的审计事件并持久化到 `baiyue_audit`。
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。 - 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
@@ -1,6 +1,7 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling @EnableScheduling
@Import(MybatisPlusConfiguration.class) @Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
@@ -1,10 +1,13 @@
package com.baiyue.audit.mapper; package com.baiyue.audit.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.entity.AuditLogEntity;
/** /**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。 * 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/ */
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> { public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
} }
@@ -40,14 +40,3 @@ baiyue:
rocketmq: rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
management.endpoints.web.exposure.include: health,info,prometheus 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}"
+6 -3
View File
@@ -2,6 +2,7 @@
## 职责与边界 ## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 - 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
@@ -13,9 +14,10 @@
|---|---| |---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | | 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` | | 注册协作 | `service/MerchantRegistrationService.java` |
@@ -26,10 +28,11 @@
## 修改约束 ## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`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 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
## 全量验收导航 ## 全量验收导航
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。 订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。
@@ -1,5 +1,6 @@
package com.baiyue.auth; package com.baiyue.auth;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -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") @EnableFeignClients(basePackages = "com.baiyue.auth.client")
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueAuthApplication { public class BaiyueAuthApplication {
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@@ -16,13 +17,18 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request); @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") @DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate( ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId); @PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName, record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail, String legalPerson, String administratorPhone, String contactEmail,
String countryCode, String address) { } String countryCode, String address) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
@@ -77,7 +77,7 @@ public class AuthController {
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @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) { public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request)); return ApiResponse.success(registrationService.register(request));
} }
@@ -85,6 +85,20 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null); 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") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { } String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId, 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) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId, @NotBlank String displayName, @NotNull Long tenantId,
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
String accountStatus) { String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName, public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) { 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 UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest( public record RevokeAccountSessionsRequest(
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
public record ImpersonationExchangeRequest(@NotBlank String code) { } public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } @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) { LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) { 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** /**
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。 * 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
*/ */
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据") @Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
public record LoginRequest( 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 @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") @Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
public record MerchantRegisterRequest( 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(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName, @NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson, @Size(max = 64) String legalPerson,
@NotBlank @Size(max = 32) String legalPhone, @Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail, @Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode, @Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address @Size(max = 255) String address
@@ -2,6 +2,8 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Set;
/** /**
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。 * 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
*/ */
@@ -15,6 +17,12 @@ public record TokenResponse(
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken, format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn 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 { public class AuthAccount {
@TableId private Long id; @TableId private Long id;
private String username; private String username;
private String phone;
private String passwordHash; private String passwordHash;
private String displayName; private String displayName;
private String accountStatus; private String accountStatus;
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。 * 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { } public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.entity.AuthRefreshToken;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。 * 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { } public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
.last("LIMIT 1")); .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) { public boolean existsByUsername(String username) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>() return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)) > 0; .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 void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) { public AuthAccount findById(Long id) {
@@ -71,9 +71,13 @@ public class AccountManagementService {
if (repository.existsByUsername(request.username())) { if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
} }
if (request.phone() != null && repository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(request.accountId()); account.setId(request.accountId());
account.setUsername(request.username()); account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password())); account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus()); account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
@@ -117,6 +121,45 @@ public class AccountManagementService {
bumpVersion(account); 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 @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) { public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
@@ -185,7 +228,7 @@ public class AccountManagementService {
} }
private AccountSummary summary(AuthAccount a) { 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()); a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
} }
@@ -201,6 +244,7 @@ public class AccountManagementService {
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) { private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username()) return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId()) && Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId()) && Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId()) && Objects.equals(account.getDefaultMembershipId(), request.membershipId())
@@ -57,10 +57,13 @@ public class AuthService {
@Transactional @Transactional
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username", @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false) description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) { 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(); if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus())) if (!"ACTIVE".equals(account.getAccountStatus()))
@@ -147,7 +150,8 @@ public class AuthService {
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(), overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn()); overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
} }
@Transactional @Transactional
@@ -201,17 +205,31 @@ public class AuthService {
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15)); authorization.getPermissions(), 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() { private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build(); return JwsHeader.with(MacAlgorithm.HS256).build();
} }
private static BizException invalidCredentials() { 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", @BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
resourceId = "#request.username", description = "商户注册", captureAfter = false) resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) { public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) { AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
throw new BizException(ResultCode.CONFLICT, "Username already exists"); 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(); long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password()); String passwordHash = passwordEncoder.encode(request.password());
@@ -46,7 +58,7 @@ public class MerchantRegistrationService {
try { try {
var response = onboardingClient.register(internalToken, var response = onboardingClient.register(internalToken,
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(), request.companyName(), request.legalPerson(), request.phone(),
request.contactEmail(), request.countryCode(), request.address())); request.contactEmail(), request.countryCode(), request.address()));
if (response == null) { if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
@@ -81,6 +93,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(accountId); account.setId(accountId);
account.setUsername(request.username()); account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordHash); account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE"); account.setAccountStatus("ACTIVE");
@@ -94,7 +107,7 @@ public class MerchantRegistrationService {
try { try {
accountRepository.insert(account); accountRepository.insert(account);
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists"); BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
failure.addSuppressed(exception); failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted); compensateOnboarding(accountId, failure, compensationCompleted);
throw failure; throw failure;
@@ -107,6 +120,24 @@ public class MerchantRegistrationService {
"LOGIN_AND_COMPLETE_QUALIFICATIONS"); "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) { private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return; return;
@@ -19,21 +19,12 @@ import org.springframework.stereotype.Service;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ /** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service @Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") @ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService { public class OverseasTokenService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:",
"platform:overseas-business:",
"platform:overseas-master-data:",
"lastleg:"
);
@Resource private AuthAccountRepository accounts; @Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader; @Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties; @Resource private OverseasJwtProperties properties;
@@ -58,8 +49,6 @@ public class OverseasTokenService {
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId()) .claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) { if (authorization.getOverseasWarehouseId() != null) {
@@ -68,15 +57,15 @@ public class OverseasTokenService {
JwtClaimsSet claims = claimsBuilder.build(); JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from( String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(),
authorization.getRoles(), authorization.getPermissions());
} }
private Set<String> overseasAuthorities(Set<String> permissions) { public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn,
if (permissions == null || permissions.isEmpty()) return Set.of(); java.util.Set<String> roles, java.util.Set<String> authorities) {
return permissions.stream() public OverseasTokenResponse {
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith)) roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
.collect(Collectors.toUnmodifiableSet()); authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
} }
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
} }
-11
View File
@@ -52,14 +52,3 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:} url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
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 ( CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)', id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定', 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 '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username), UNIQUE KEY uk_auth_account_username (username),
UNIQUE KEY uk_auth_account_phone (phone),
KEY idx_auth_account_status (account_status, locked_until) KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; ) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
@@ -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");
}
}
@@ -19,8 +19,8 @@ class InitialAccountSeedSqlContractTest {
private static void assertAccountSeed(String sql) { private static void assertAccountSeed(String sql) {
assertThat(sql) assertThat(sql)
.contains("(1000001, 'platform_admin'") .containsPattern("\\(1000001\\s*,\\s*'platform_admin'")
.contains("'平台系统管理员', 'ACTIVE'") .containsPattern("'平台系统管理员'\\s*,\\s*'ACTIVE'")
.contains("password_change_required") .contains("password_change_required")
.doesNotContain("merchant_admin") .doesNotContain("merchant_admin")
.doesNotContain("IT_TEST") .doesNotContain("IT_TEST")
@@ -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.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; 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.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -157,6 +159,44 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L); 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() { @Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount(); AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE"); second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM"); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1); account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); "WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account); when(repository.findByUsername("operator")).thenReturn(account);
@@ -72,7 +72,8 @@ class AuthServiceSmallJwtTest {
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token"); when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn( when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900)); new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.login(new LoginRequest("operator", "secret")); var response = service.login(new LoginRequest("operator", "secret"));
@@ -91,13 +92,55 @@ class AuthServiceSmallJwtTest {
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot); verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900); 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 @Test
@@ -122,7 +165,8 @@ class AuthServiceSmallJwtTest {
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn( when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900)); new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.refresh("refresh-old"); var response = service.refresh("refresh-old");
@@ -133,6 +177,8 @@ class AuthServiceSmallJwtTest {
assertThat(response.refreshExpiresIn()).isEqualTo(604800); assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900); 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).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
@@ -179,8 +225,16 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); 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); ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture()); verify(jwtEncoder).encode(jwt.capture());
@@ -193,5 +247,6 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); 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.doThrow;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
@SpringJUnitConfig(MerchantRegistrationService.class) @SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@@ -54,11 +55,56 @@ class MerchantRegistrationServiceTest {
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId()); assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode()); assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("13800138000", storedAccount.getValue().getPhone());
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus()); 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 @Test
void failedAuthInsertCompensatesTheIamRegistration() { void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password"); when(encoder.encode("Password123")).thenReturn("encoded-password");
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId); Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
} }
private void inject(String fieldName, Object value) throws Exception { private void inject(String fieldName, Object value) throws Exception {
@@ -49,10 +49,15 @@ class OverseasTokenServiceTest {
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
String authority = "platform:overseas-master-data:quote:list"; 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 lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list"; String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority,
financeAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account); when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -74,10 +79,12 @@ class OverseasTokenServiceTest {
.containsEntry("permission_version", 7L) .containsEntry("permission_version", 7L)
.containsEntry("token_version", 0) .containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L) .containsEntry("overseas_warehouse_id", 900L)
.containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
.containsEntry("authorities", Set.of(authority, lastlegAuthority)) "permissionVersion");
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900); assertThat(response.expiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority,
merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority);
} }
@Test @Test
@@ -87,9 +94,9 @@ class OverseasTokenServiceTest {
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8); account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L); Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L); Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class); Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class); Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second); when(encoder.encode(any())).thenReturn(first, second);
@@ -117,7 +124,7 @@ class OverseasTokenServiceTest {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE"); account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null); Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded); when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound"); when(encoded.getTokenValue()).thenReturn("overseas-unbound");
@@ -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("统一定义");
}
}
+7 -4
View File
@@ -14,14 +14,17 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 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 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外卸柜完成 V1 | `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 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外派送完成 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`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.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` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证 ## 修改与验证
@@ -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;
}
}
@@ -14,6 +14,10 @@ public final class EventTypes {
"FirstLegContainerShippingNodeRecorded"; "FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; 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_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -1,14 +1,14 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1( public record FirstLegContainerShippingNodeRecordedV1(
String queryId, String queryId,
String customerId,
String oceanBookingId, String oceanBookingId,
String containerBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String containerNo,
String standardNode, String standardNode,
int nodeSequence, int nodeSequence,
@@ -28,12 +28,17 @@ public record FirstLegContainerShippingNodeRecordedV1(
"EMPTY_RETURNED", 100); "EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 { public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() if (queryId != null && queryId.isBlank()) {
|| oceanBookingId == null || oceanBookingId.isBlank() throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
|| containerBookingId == null || containerBookingId.isBlank() }
if (oceanBookingId != null && oceanBookingId.isBlank()) {
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
}
if (containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
} }
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) { || observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1( public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { 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( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets, BigDecimal volumeWeightKg,
String destinationType) { List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType, String lxCode) {
public Order { public Order {
items = items == null ? List.of() : items; 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; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, null);
} }
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,27 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); 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);
} }
} }
@@ -1,13 +1,38 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */ /** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
public record FirstLegLoadingPlanCompletedV2( public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { 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( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets, BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) { String destinationType, String lxCode) {
public Order { public Order {
items = items == null ? List.of() : items; 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; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, null);
} }
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +82,27 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); 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);
} }
} }
@@ -86,11 +141,36 @@ public record FirstLegLoadingPlanCompletedV2(
} }
} }
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */ /** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {} 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);
}
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
public record SkuPackageSnapshot(String skuCode, String productName, this(palletNo, skuPackages, null, null, null, null);
}
}
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
int packageCount, int productQuantity, int packageCount, int productQuantity,
List<String> cartonMarks) {} 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; package com.baiyue.common.event;
import java.time.Instant; import java.time.Instant;
import java.util.Set;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */ /** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 { public final class IamSecurityEventsV1 {
@@ -14,5 +15,11 @@ public final class IamSecurityEventsV1 {
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, 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);
}
}
} }
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
Instant unloadingPhotoUploadedAt, Instant unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt 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,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");
}
}
@@ -21,18 +21,43 @@ class FirstLegContainerShippingNodeRecordedV1Test {
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80); 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 @Test
void rejectsEstimatedOnlyOrIncompleteProviderFacts() { void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, "1", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:00:00Z"), null, null, null, Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64))) "ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间"); .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60, "1", "3", "4", "ZCSU7881970", "ARRIVED", 60,
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"), 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))) null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(IllegalArgumentException.class);
@@ -40,7 +65,7 @@ class FirstLegContainerShippingNodeRecordedV1Test {
private static FirstLegContainerShippingNodeRecordedV1 payload() { private static FirstLegContainerShippingNodeRecordedV1 payload() {
return new FirstLegContainerShippingNodeRecordedV1( return new FirstLegContainerShippingNodeRecordedV1(
"2088000000000000001", "2088000000000000002", "2088000000000000004", "2088000000000000001", "2088000000000000004",
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), "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", var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload); String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003")); "SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, "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)), List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """ String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", "warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} "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); FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); 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");
} }
} }
@@ -4,18 +4,88 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test { 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 @Test
void carriesTheThreeItemRemarks() throws Exception { void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null, 2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注"); "客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item)); null, null, null, 1, 2, null, null, null, null, List.of(item),
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); "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); String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText()) assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
@@ -25,29 +95,31 @@ class FirstLegLoadingPlanCompletedV2Test {
@Test @Test
void carriesDropshipPalletAndCartonSnapshots() throws Exception { void carriesDropshipPalletAndCartonSnapshots() throws Exception {
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot( var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002")); "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 pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order( var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null, "2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet)); 2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2( var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload); var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11"); 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/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/cartonMarks")).hasSize(2);
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()).isEqualTo("101");
} }
@Test @Test
void carriesFrozenDestinationType() { void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order( var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); 2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA"); assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
} }
@@ -58,12 +130,55 @@ class FirstLegLoadingPlanCompletedV2Test {
20, null, null, null, null, null, null, null, null, null, null, null, 20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item)); null, null, null, 2, 20, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); "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/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); 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");
}
}
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
import java.time.Instant; import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test { class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test @Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload); String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt()); assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(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");
}
}
@@ -1,6 +1,7 @@
package com.baiyue.common.security; package com.baiyue.common.security;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import jakarta.servlet.http.HttpServletRequest;
/** /**
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。 * 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
@@ -8,4 +9,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
@FunctionalInterface @FunctionalInterface
public interface AccessTokenSessionValidator { public interface AccessTokenSessionValidator {
AccessTokenSession validate(Jwt jwt); AccessTokenSession validate(Jwt jwt);
default AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
return validate(jwt);
}
} }
@@ -50,9 +50,11 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis, org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) { @Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator; AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
else { else {
StringRedisTemplate template=redis.getIfAvailable(); StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); 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.oauth2.jwt.JwtException;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
@@ -19,6 +21,7 @@ import java.io.IOException;
* Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。 * Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。
*/ */
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger LOG = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER = "Bearer "; private static final String BEARER = "Bearer ";
private final JwtDecoder jwtDecoder; private final JwtDecoder jwtDecoder;
private final JwtClaimsMapper claimsMapper; private final JwtClaimsMapper claimsMapper;
@@ -55,11 +58,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenValue = header.substring(BEARER.length()).trim(); String tokenValue = header.substring(BEARER.length()).trim();
try { try {
Jwt jwt = jwtDecoder.decode(tokenValue); Jwt jwt = jwtDecoder.decode(tokenValue);
AccessTokenSession session = sessionValidator.validate(jwt); AccessTokenSession session = sessionValidator.validate(jwt, request);
Authentication authentication = claimsMapper.map(jwt, session); Authentication authentication = claimsMapper.map(jwt, session);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} catch (JwtException | IllegalArgumentException exception) { } catch (JwtException | IllegalArgumentException exception) {
LOG.warn("JWT authentication rejected for {}: {}", request.getRequestURI(), exception.getMessage());
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
authenticationEntryPoint.commence(request, response, authenticationEntryPoint.commence(request, response,
new InvalidJwtAuthenticationException("Invalid or expired access token", exception)); new InvalidJwtAuthenticationException("Invalid or expired access token", exception));
@@ -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(invoked).isTrue();
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); 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);
}
} }
+4
View File
@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId> <artifactId>spring-security-core</artifactId>
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException; 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.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -64,7 +67,8 @@ public class GlobalExceptionHandler {
} }
@ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class, @ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class}) MissingServletRequestParameterException.class, MissingServletRequestPartException.class,
MethodArgumentTypeMismatchException.class})
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
} }
@@ -96,6 +100,24 @@ public class GlobalExceptionHandler {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), 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) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}", log.error("Unhandled request failure requestId={} traceId={}",
@@ -16,6 +16,9 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -63,6 +66,17 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
} }
@Test
void missingRequiredMultipartPartMapsToBadRequest() {
MockHttpServletRequest request = new MockHttpServletRequest();
var response = handler.handleBadRequest(
new MissingServletRequestPartException("file"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
}
@Test @Test
void validationDetailsAreIncludedInMessageAndData() { void validationDetailsAreIncludedInMessageAndData() {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
@@ -116,6 +130,32 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code()); assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
} }
@Test
void lockWaitTimeoutMapsToCanonicalConflict() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
var response = handler.handleCannotAcquireLock(
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
}
@Test
void oversizedMultipartReturnsClearBadRequestInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "oversized-photo");
var response = handler.handleMaxUploadSize(
new MaxUploadSizeExceededException(10L * 1024 * 1024), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
assertThat(response.getBody().message()).isEqualTo("上传文件不能超过10MB");
assertThat(response.getBody().requestId()).isEqualTo("oversized-photo");
}
private void paging(long page) { private void paging(long page) {
} }
} }
-3
View File
@@ -50,9 +50,6 @@ spring:
client.config.default: client.config.default:
connect-timeout: 3000 connect-timeout: 3000
read-timeout: 5000 read-timeout: 5000
logging:
file:
path: ${LOG_PATH:/app/logs}
server: server:
shutdown: graceful shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
+2
View File
@@ -11,3 +11,5 @@ baiyue:
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events}
finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events}
+20
View File
@@ -22,6 +22,26 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+3
View File
@@ -5,3 +5,6 @@ spring:
password: ${FINANCE_DB_PASSWORD} password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:}
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
+5
View File
@@ -4,6 +4,10 @@ spring:
username: ${FIRSTLEG_DB_USERNAME} username: ${FIRSTLEG_DB_USERNAME}
password: ${FIRSTLEG_DB_PASSWORD} password: ${FIRSTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
servlet:
multipart:
max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
@@ -17,6 +21,7 @@ baiyue:
emc-automated: ${EMC_TRACKING_ENABLED:false} emc-automated: ${EMC_TRACKING_ENABLED:false}
hede-automated: ${HEDE_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false}
scheduler: scheduler:
enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false}
zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
+10
View File
@@ -3,3 +3,13 @@ spring.datasource:
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root} username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456} password: ${AUTH_DB_PASSWORD:123456}
baiyue:
security:
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:true}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
-3
View File
@@ -47,9 +47,6 @@ spring:
client.config.default: client.config.default:
connect-timeout: 3000 connect-timeout: 3000
read-timeout: 5000 read-timeout: 5000
logging:
file:
path: ${LOG_PATH:./logs}
server: server:
shutdown: graceful shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
+20
View File
@@ -18,6 +18,26 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 batch-size: 500
DOMESTIC_WAREHOUSE_ORDER_LIST:
provider-service: order-service
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
required-permission: platform:order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
LOADING_LIST_BUY_DECLARATION_ITEMS:
provider-service: firstleg-service
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
required-permission: platform:firstleg:customs:manage
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+3
View File
@@ -5,3 +5,6 @@ spring:
username: ${FINANCE_DB_USERNAME:root} username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456} password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.wechat-qr-code-url: ${FINANCE_RECHARGE_WECHAT_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.recharge.alipay-qr-code-url: ${FINANCE_RECHARGE_ALIPAY_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
+3
View File
@@ -3,6 +3,8 @@ spring.datasource:
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FIRSTLEG_DB_USERNAME:root} username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456} password: ${FIRSTLEG_DB_PASSWORD:123456}
spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
@@ -13,6 +15,7 @@ baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_E
baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false}
baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false}
baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai}
baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false}
baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M}
baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S}
baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20}
+3
View File
@@ -4,6 +4,9 @@ spring.datasource:
username: ${IAM_DB_USERNAME:root} username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456} password: ${IAM_DB_PASSWORD:123456}
baiyue: baiyue:
security-event:
# 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
customer-dimension-initialization: customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
+1 -1
View File
@@ -23,7 +23,7 @@ baiyue:
security: security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks jwt-mode: jwks
session-mode: claims session-mode: gateway
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api} audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -13,7 +13,7 @@ baiyue:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security: security:
jwt-mode: jwks jwt-mode: jwks
session-mode: claims session-mode: gateway
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api} audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+1
View File
@@ -40,6 +40,7 @@ spring:
connect-timeout: 3000 connect-timeout: 3000
read-timeout: 5000 read-timeout: 5000
server: server:
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
shutdown: graceful shutdown: graceful
management: management:
endpoints: endpoints:
+15
View File
@@ -1,4 +1,8 @@
spring: spring:
servlet:
multipart:
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
datasource: datasource:
url: ${LASTLEG_DB_URL} url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME} username: ${LASTLEG_DB_USERNAME}
@@ -18,18 +22,29 @@ baiyue:
relabel-photo-file: relabel-photo-file:
connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s}
customer-visit-file:
connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s}
procurement-file:
connect-timeout: ${LASTLEG_PROCUREMENT_FILE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PROCUREMENT_FILE_READ_TIMEOUT:30s}
warehouse-resource: warehouse-resource:
connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s}
pallet-physical: pallet-physical:
connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s}
domestic-loading-evidence:
connect-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_CONNECT_TIMEOUT:3s}
read-timeout: ${LASTLEG_DOMESTIC_LOADING_EVIDENCE_READ_TIMEOUT:30s}
clients: clients:
domestic-warehouse: domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL} url: ${DOMESTIC_WAREHOUSE_URL}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file: file:
url: ${FILE_SERVICE_URL} url: ${FILE_SERVICE_URL}
domestic-file:
url: ${FILE_SERVICE_URL}
event-bridge: event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}}
warehouse: warehouse:
+12 -11
View File
@@ -1,15 +1,15 @@
name: firstleg-${DEPLOY_ENV:-test}-overseas name: firstleg-${DEPLOY_ENV:-test}-overseas
x-overseas-common: &overseas-common x-overseas-common: &overseas-common
restart: unless-stopped restart: "on-failure:3"
networks: [firstleg-overseas-network, infra-network] networks: [firstleg-overseas-network, infra-network]
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: &overseas-environment environment: &overseas-environment
SPRING_PROFILES_ACTIVE: overseas SPRING_PROFILES_ACTIVE: overseas
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${NACOS_USERNAME} NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME}
NACOS_PASSWORD: ${NACOS_PASSWORD} NACOS_PASSWORD: ${OVERSEAS_NACOS_PASSWORD}
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas}
REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal}
@@ -24,13 +24,14 @@ x-overseas-common: &overseas-common
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
DATA_RESIDENCY: OVERSEAS DATA_RESIDENCY: OVERSEAS
SPRING_SQL_INIT_MODE: never SPRING_SQL_INIT_MODE: never
LOG_PATH: /app/logs SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
TZ: Asia/Shanghai TZ: Asia/Shanghai
logging: logging:
driver: json-file # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
driver: ${DOCKER_LOG_DRIVER:-json-file}
options: options:
max-size: 100m max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "5" max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services: services:
gateway-overseas: gateway-overseas:
@@ -38,7 +39,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
SERVER_PORT: 9300 SERVER_PORT: 9300
@@ -55,7 +55,6 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
SERVER_PORT: 9303 SERVER_PORT: 9303
@@ -66,6 +65,8 @@ services:
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL}
# Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
warehouse-overseas-service: warehouse-overseas-service:
@@ -73,7 +74,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
SERVER_PORT: 9305 SERVER_PORT: 9305
@@ -81,13 +81,14 @@ services:
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
# 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
event-bridge-service: event-bridge-service:
<<: *overseas-common <<: *overseas-common
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
environment: environment:
<<: *overseas-environment <<: *overseas-environment
SERVER_PORT: 9312 SERVER_PORT: 9312
+12 -3
View File
@@ -1,15 +1,22 @@
name: firstleg-${DEPLOY_ENV:-test}-rocketmq name: firstleg-${DEPLOY_ENV:-test}-rocketmq
x-bounded-logging: &bounded-logging
driver: ${DOCKER_LOG_DRIVER:-json-file}
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services: services:
rocketmq-namesrv: rocketmq-namesrv:
image: apache/rocketmq:5.3.1 image: apache/rocketmq:5.3.1
restart: unless-stopped restart: "on-failure:3"
command: sh mqnamesrv command: sh mqnamesrv
logging: *bounded-logging
networks: [infra-network] networks: [infra-network]
rocketmq-broker: rocketmq-broker:
image: apache/rocketmq:5.3.1 image: apache/rocketmq:5.3.1
restart: unless-stopped restart: "on-failure:3"
depends_on: [rocketmq-namesrv] depends_on: [rocketmq-namesrv]
environment: environment:
ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required} ROCKETMQ_BROKER_IP: ${ROCKETMQ_BROKER_IP:?ROCKETMQ_BROKER_IP is required}
@@ -17,6 +24,7 @@ services:
- ./rocketmq:/config:ro - ./rocketmq:/config:ro
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store - ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
command: sh /config/render-broker-config.sh command: sh /config/render-broker-config.sh
logging: *bounded-logging
networks: [infra-network] networks: [infra-network]
rocketmq-topic-init: rocketmq-topic-init:
@@ -45,7 +53,8 @@ services:
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6' -t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。 # Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
restart: on-failure restart: "on-failure:3"
logging: *bounded-logging
networks: [infra-network] networks: [infra-network]
networks: networks:
+17 -17
View File
@@ -1,12 +1,13 @@
name: firstleg-${DEPLOY_ENV:-test} name: firstleg-${DEPLOY_ENV:-test}
x-common: &common x-common: &common
restart: unless-stopped restart: "on-failure:3"
networks: [firstleg-network, infra-network] networks: [firstleg-network, infra-network]
# 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。 # 测试机复用宿主机的 MySQL、Redis、Nacos 时,容器通过此别名访问宿主机。
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: &common-environment environment: &common-environment
DEPLOY_ENV: ${DEPLOY_ENV:-test}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn}
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
NACOS_USERNAME: ${NACOS_USERNAME} NACOS_USERNAME: ${NACOS_USERNAME}
@@ -26,13 +27,13 @@ x-common: &common
REGION_CODE: ${REGION_CODE:-CN} REGION_CODE: ${REGION_CODE:-CN}
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
LOG_PATH: /app/logs
TZ: Asia/Shanghai TZ: Asia/Shanghai
logging: logging:
driver: json-file # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
driver: ${DOCKER_LOG_DRIVER:-json-file}
options: options:
max-size: 100m max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "5" max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services: services:
gateway: gateway:
@@ -40,7 +41,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9000 SERVER_PORT: 9000
@@ -53,7 +53,6 @@ services:
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9100 SERVER_PORT: 9100
@@ -72,7 +71,6 @@ services:
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9201 SERVER_PORT: 9201
@@ -86,7 +84,6 @@ services:
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9202 SERVER_PORT: 9202
@@ -100,32 +97,39 @@ services:
COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false}
EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false}
HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false}
CARRIER_SCHEDULE_SCHEDULER_ENABLED: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:-false}
CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai}
CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M} CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M}
CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S} CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S}
CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20}
CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000}
CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M}
TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false}
TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-}
TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587}
TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-}
TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-}
TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false}
TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000}
finance: finance:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9203 SERVER_PORT: 9203
FINANCE_DB_URL: ${FINANCE_DB_URL} FINANCE_DB_URL: ${FINANCE_DB_URL}
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
order: order:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9204 SERVER_PORT: 9204
@@ -138,7 +142,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9205 SERVER_PORT: 9205
@@ -153,7 +156,6 @@ services:
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
volumes: volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9206 SERVER_PORT: 9206
@@ -175,7 +177,6 @@ services:
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9207 SERVER_PORT: 9207
@@ -186,7 +187,6 @@ services:
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9210 SERVER_PORT: 9210
@@ -236,7 +236,6 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9312 SERVER_PORT: 9312
@@ -245,6 +244,8 @@ services:
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events}
FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events}
SPRING_SQL_INIT_MODE: never SPRING_SQL_INIT_MODE: never
networks: networks:
firstleg-network: firstleg-network:
@@ -256,7 +257,6 @@ services:
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-audit container_name: firstleg-${DEPLOY_ENV:-test}-audit
expose: ["9700"] expose: ["9700"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
environment: environment:
<<: *common-environment <<: *common-environment
SERVER_PORT: 9700 SERVER_PORT: 9700
+1 -1
View File
@@ -15,7 +15,7 @@ RUN addgroup -S app \
WORKDIR /app WORKDIR /app
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。 # 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs RUN mkdir -p /data/files && chown -R app:app /data
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。 # 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./ COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
-34
View File
@@ -2,38 +2,4 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0" <settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"> xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共镜像</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<profiles>
<profile>
<id>国内公共镜像</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内公共镜像</activeProfile>
</activeProfiles>
</settings> </settings>
+19 -9
View File
@@ -29,9 +29,9 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do
done done
case "$DEPLOY_ENV" in case "$DEPLOY_ENV" in
development|test) ;; development|test|production) ;;
*) *)
echo "直接镜像传输仅支持 development/test,当前: $DEPLOY_ENV" >&2 echo "仅支持 development/test/production,当前: $DEPLOY_ENV" >&2
exit 2 exit 2
;; ;;
esac esac
@@ -114,26 +114,32 @@ if [ "$SERVICE" = "finance" ]; then
done done
fi fi
case "$SERVICE" in if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then
gateway-overseas|lastleg-service|warehouse-overseas-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署海外服务时,环境文件必须包含非空 $key" >&2 echo "部署海外服务时,环境文件必须包含非空 $key" >&2
exit 2 exit 2
fi fi
done done
;; fi
esac
if [ "$SERVICE" = "firstleg" ]; then if [ "$SERVICE" = "firstleg" ]; then
if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
exit 2 exit 2
fi fi
if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then
for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
fi fi
if [ "$SERVICE" = "lastleg-service" ]; then if [ "$SERVICE" = "lastleg-service" ]; then
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2 exit 2
@@ -182,6 +188,10 @@ if [ "$SERVICE" = "file" ]; then
echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2 echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2
exit 2 exit 2
fi fi
if [ "$DEPLOY_ENV" = "production" ] && [ -n "$MQ_PREFIX" ]; then
echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为空,使用正式 Topic" >&2
exit 2
fi
fi fi
: "${REGISTRY_USER:?REGISTRY_USER 未设置}" : "${REGISTRY_USER:?REGISTRY_USER 未设置}"
@@ -499,7 +509,7 @@ if [ "$new_started" = "true" ] && wait_for_health; then
fi fi
echo "新镜像健康检查失败: $new_image" >&2 echo "新镜像健康检查失败: $new_image" >&2
failed_container_id="$(remote_container_id || true)" failed_container_id="$(container_id || true)"
if [ -n "$failed_container_id" ]; then if [ -n "$failed_container_id" ]; then
echo "新镜像容器最近日志(最多 200 行):" >&2 echo "新镜像容器最近日志(最多 200 行):" >&2
docker logs --tail 200 "$failed_container_id" 2>&1 || true docker logs --tail 200 "$failed_container_id" 2>&1 || true
+2 -2
View File
@@ -72,7 +72,7 @@ else
fi fi
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署海外服务时,环境文件必须包含非空 $key" >&2 echo "部署海外服务时,环境文件必须包含非空 $key" >&2
exit 2 exit 2
@@ -111,7 +111,7 @@ esac
case " $SERVICES " in case " $SERVICES " in
*" lastleg-service "*) *" lastleg-service "*)
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
exit 2 exit 2
+50 -202
View File
@@ -1,228 +1,76 @@
# 测试环境 SQL 执行说明 # 区域数据库空库初始化
## 唯一数据库初始化与发布入口 ## 唯一入口与区域边界
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 当前开发阶段只维护纯初始化,不提供存量升级、数据回填、清理或兼容入口。两个 SQL 文件沿用原文件名,名称中的“增量”不表示可向已有业务库重放。
- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。
- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
后续追加增量必须满足: | 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 |
1. 只新增或兼容升级,不清表、不删除业务数据。 - 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
2. 新字段、索引、约束必须先查询 `information_schema`,允许整份脚本重复执行。 - 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。
3. 系统权限或字典配置使用唯一键和 `ON DUPLICATE KEY UPDATE` 幂等写入。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。
## Finance 首期人工财务基线 ## 执行前提与失败边界
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 1. 获得目标环境、精确数据库范围的当轮授权。初始化授权不等于清库授权;两个 SQL 本身不删除数据库、表或业务数据。
- 首期只支持 CNY,包含平台主体、客户默认 30 天账期、账单及明细、人工收款及账单分配、单票/整柜人工成本、体积分摊结果、Firstleg 费用来源事件、幂等记录和不可变审计日志。 2. MySQL 8.0+,目标区域 schema 必须不存在或为空。已有同名表时报错,不使用 `CREATE TABLE IF NOT EXISTS` 绕过旧结构。
- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。 3. 执行用户需要目标区域建库、建表、索引、引用、触发器及种子插入权限,不需要另一地区的访问权。
- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。 4. 客户端须支持 `DELIMITER`。完整执行文件,关闭“遇错继续”,禁止 `mysql --force`。不要选中片段执行,不要前后串行执行模块 SQL。
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
## 历史一期回归脚本 成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
- 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。 ## 平台管理员密码
- 适用数据库:MySQL 8.0。
- 执行账号需要对 `baiyue_iam`、`baiyue_warehouse`、`baiyue_order`、`baiyue_firstleg` 具备建表、改表、建索引、创建临时存储过程和数据写入权限。
- 脚本不会创建数据库,不会清理或删除业务数据,也不会写入演示租户、演示用户、演示仓库、演示往来单位或快递公司样本。
- 脚本可重复执行。MySQL 的 DDL 会隐式提交,因此不能把整份跨库脚本视为可整体回滚的事务。
- 该历史脚本曾内联 IAM 客户档案字段迁移和 `platform:customer:view` 权限迁移,当前不得再单独执行。
## IAM 来源与执行顺序 国内只初始化一个 `platform_admin` 及对应 IAM 用户、成员和角色关系,不创建测试客户或客户账号。密码不在源码内置。
固定入口中的 IAM 段按以下模块迁移依赖顺序组织,并通过元数据判断或 `ON DUPLICATE KEY UPDATE` 保持可重复执行: 在**同一连接**中,先由安全外部配置设置会话变量 `@platform_admin_password_hash`,再执行国内文件。值须为实际初始密码生成的 bcrypt 哈希(60 字符);不要传明文、示例哈希或把真实值存进仓库。`password_change_required=1`,初次登录必须改密。
1. IAM 基线表必须已存在(`iam.sql`)。 变量缺失或格式错误会在 Auth 账号插入时被非空约束拒绝;Auth 表可能已创建,属于失败的半初始化,应停止。海外文件不需要密码变量。
2. 权限父菜单和既有客户列表/审核权限基线(`phase1-admin-rbac.sql`、`rbac-role-management-v2.sql`)。
3. `phase1-platform-master-data-and-permission-management.sql` 中的 `platform:customer:view`、既有角色继承及管理员授权。
4. `phase1-customer-profile.sql` 中的十个 `iam_tenant` 客户档案字段。
5. `phase1-onboarding.sql` 中的 `PROFILE_REQUIRED` 默认状态、审核结果字段、资质审核备注及成员审计字段。
固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。 本地验收入口读取当前进程环境变量 `INIT_PLATFORM_ADMIN_PASSWORD_HASH`,经标准输入传入同一 MySQL 会话,不写入命令行、暂存 SQL 或日志。数据库连接认证独立由外部登录配置或 `MYSQL_PWD` 提供,不要与平台账号密码混淆。
测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。 ## 审核保留的系统种子
## 执行方式 | 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、346 个权限、521 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 |
在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。
执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。 扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。
执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 示例 `TP-001`、`TP-002`、`YW-TP-002`、`DG-TP-002`、A1/A0 库位、`EXP-TEST`、`CAR-001` 不再初始化。验收资源通过业务入口配置;既有只复用托盘和库位的造数脚本须先满足各自前置条件,不能默认清库后已存在。
## 三期分柜、预装、拣货与装柜当前基线 ## 维护规则
- 模块表结构来源:`firstleg/src/main/resources/db/phase3-split-preload-schema.sql`、`firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql`。 - 最终字段、类型、默认值、索引、外键、CHECK、注释直接维护在 CREATE TABLE;64 个国内业务触发器保留最终定义。不追加 ALTER、迁移过程、动态 DDL、UPDATE 回填或 DELETE 清理段。
- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。 - 系统种子直接维护最终 INSERT,不保留退役 ID 清理或 ON DUPLICATE KEY UPDATE 升级。权限变更联查 IAM 来源、Controller、OpenAPI 与调用端。
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。 - 原 `权限点初始化-MySQL8.sql` 独立补丁已撤销,订单申报权限和默认授权已纳入国内 IAM 基线,不另设存量权限发布入口。
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。 - 模块 `src/main/resources/db/` 用作结构来源和开发追溯,不是区域部署前置;不再向历史迁移文件追加发布步骤。修改当前字段时同步模块有效建表定义及其合同。
- 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是二期有效终点,脚本不会改写主表或状态历史。 - 不在 SQL 中写入真实客户数据、环境地址、固定密码或 Token。
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
部署前可只读盘点: ## 本地验证
```sql 离线合同不连接数据库:
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN (
'fl_preload_plan', 'fl_split_operation', 'fl_loading_evidence',
'fl_loading_operation', 'fl_loading_pick_task'
)
ORDER BY table_name;
SELECT permission_code ```powershell
FROM baiyue_iam.iam_permission node --test scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs scripts/inbound-fee-unified-sql-contract.test.mjs scripts/inbound-hold-unified-sql-contract.test.mjs
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0 .\scripts\local-db-full-acceptance.ps1 -DryRun
AND permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view'
)
ORDER BY permission_code;
``` ```
## 公共导出中心存量升级 获得精确本地库清理授权并配置认证、管理员哈希后,可用 `local-db-full-acceptance.ps1 -Port <已授权端口> -ConfirmResetProjectSchemas -Rounds 2`。它删除列出的 12 个项目 schema,不得在有待保留数据的实例运行;详见 `scripts/README.md`。该入口不会自动启动隔离实例。
固定入口已包含 File 当前 6 张表,并通过 `information_schema` 为旧版 `file_export_task` 幂等补齐 8 个调度、重试和租约字段,不再允许通过 `DROP TABLE` 重建。执行完成后可额外核验: 本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
```sql 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
SELECT COUNT(*) = 3 AS export_tables_ready
FROM information_schema.tables
WHERE table_schema = 'baiyue_file'
AND table_name IN ('file_export_task', 'file_export_outbox', 'file_export_task_guard');
SELECT COUNT(*) = 1 AS creation_guard_ready
FROM baiyue_file.file_export_task_guard
WHERE guard_key = 'GLOBAL_CREATE';
SELECT COUNT(*) = 8 AS scheduler_columns_ready
FROM information_schema.columns
WHERE table_schema = 'baiyue_file' AND table_name = 'file_export_task'
AND column_name IN ('dispatch_version', 'attempt_count', 'max_attempts', 'next_retry_at',
'lease_owner', 'lease_until', 'last_heartbeat_at', 'execution_deadline_at');
```
任一结果不是 1,禁止启动 File Service;只能继续追加兼容性增量修复,不得删除存量导出任务。
## 二期第三方测量增量
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
- 历史二期脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已冻结,仅供追溯。
- 当前固定入口 `头程数据库统一增量-MySQL8.sql` 已内联该增量以及订单船司字段、二期 28 项 IAM 权限和最新仓储资源租户字段。
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
- 部署 Firstleg 新版本前必须完整执行固定入口,并确认末尾总体验证为 `PASS`。
## 稳定箱号与单箱当前测量
- 固定入口已包含 `fl_inbound_carton_label`,首次打印时按订单货物明细冻结每个实际箱的 `box_code` 和箱下标;重复打印、部分补打及设备测量均复用该映射。
- `fl_inbound_measurement_item` 已补齐 `box_code`、`machine_no`、`device_measured_at`、`updated_at`,并通过 `uk_measure_box_code (tenant_id, box_code)` 保证同一客户下每个箱号只有一条当前测量记录。
- 部署 Firstleg 新版本前必须完整执行固定入口。末尾会强校验映射表、四个测量字段及三个唯一约束,任一缺失均不会返回 `PASS`。
## 当前主体字段语义核验
- 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目是 `tenant_id = 0` 的平台公共主数据,入库费用明细仍以 `tenant_id > 0` 隔离客户并引用公共 `fee_item_id`。
- `fl_third_party_call_log.tenant_id = 0` 仅表示第三方主体未知或尚未解析的哨兵值,不是平台客户记录或平台操作作用域,绝不能据此授权、查询或写入客户业务数据。上线后可只读统计:
```sql
SELECT COUNT(*) AS unresolved_third_party_calls
FROM baiyue_firstleg.fl_third_party_call_log
WHERE tenant_id = 0;
```
- 平台公共仓库、地址、服务能力、往来单位 `wh_partner`、快递公司、托盘、库位、预占和占用固定归平台作用域 `tenant_id=0`;Firstleg 的 `warehouse_resource_tenant_id` 同步固定为 `0`。
- 客户订单、客户文件和客户费用规则仍使用 `tenant_id>0` 做客户隔离;往来单位不是客户私有数据,统一脚本会把 `wh_partner` 和 `wh_partner_type` 的作用域归零。
## 平台公共资源归零迁移与核验
测试环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不要再串行执行模块脚本。执行前可只读盘点公共资源旧归属:
```sql
SELECT 'wh_warehouse' AS resource, COUNT(*) AS total, COALESCE(SUM(tenant_id <> 0), 0) AS non_platform
FROM baiyue_warehouse.wh_warehouse
UNION ALL SELECT 'wh_warehouse_address', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_warehouse_address
UNION ALL SELECT 'wh_service_capability', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_service_capability
UNION ALL SELECT 'wh_express_company', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_express_company
UNION ALL SELECT 'wh_pallet', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_pallet
UNION ALL SELECT 'wh_location', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location
UNION ALL SELECT 'wh_location_reservation', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_reservation
UNION ALL SELECT 'wh_location_occupancy', COUNT(*), COALESCE(SUM(tenant_id <> 0), 0) FROM baiyue_warehouse.wh_location_occupancy
UNION ALL SELECT 'fl_inbound_pallet_allocation.resource_scope', COUNT(*),
COALESCE(SUM(warehouse_resource_tenant_id <> 0), 0)
FROM baiyue_firstleg.fl_inbound_pallet_allocation;
```
脚本会先把明确公共资源迁到 `0`,按父仓库回填地址和能力,再迁移平台往来单位、托盘、库位、预占、占用及 Firstleg 资源归属快照。末尾 `PASS` 前会强校验公共资源(含往来单位)全部归零、父子一致,以及客户费用规则不存在 `tenant_id=0`。执行后可再只读核验:
```sql
SELECT
(SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner
WHERE tenant_id <> 0 OR data_scope_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_partner_type
WHERE tenant_id <> 0 OR data_scope_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_express_company WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_reservation WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location_occupancy WHERE tenant_id <> 0)
+ (SELECT COUNT(*) FROM baiyue_firstleg.fl_inbound_pallet_allocation
WHERE warehouse_resource_tenant_id <> 0) AS public_scope_violations,
(SELECT COUNT(*) FROM baiyue_warehouse.wh_overseas_storage_fee_rule WHERE tenant_id = 0)
AS customer_scope_violations;
SELECT
(SELECT COUNT(*) FROM baiyue_warehouse.wh_warehouse_address a
JOIN baiyue_warehouse.wh_warehouse w ON w.id = a.warehouse_id
WHERE a.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_service_capability c
JOIN baiyue_warehouse.wh_warehouse w ON w.id = c.warehouse_id
WHERE c.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_pallet p
JOIN baiyue_warehouse.wh_warehouse w ON w.id = p.warehouse_id
WHERE p.tenant_id <> w.tenant_id)
+ (SELECT COUNT(*) FROM baiyue_warehouse.wh_location l
JOIN baiyue_warehouse.wh_warehouse w ON w.id = l.warehouse_id
WHERE l.tenant_id <> w.tenant_id) AS parent_child_scope_mismatches;
```
上述三个结果 `public_scope_violations`、`customer_scope_violations`、`parent_child_scope_mismatches` 必须都为 `0`。
## 船司 FBA 价格公共作用域
- 当前测试环境 `fl_carrier_fba_rate` 为空表,不执行存量价格迁移。
- 初始化表结构将船司 FBA 价格定义为平台公共价格,Firstleg 创建、查询和计费匹配固定使用 `tenant_id = 0`;客户计费规则仍按客户 `tenant_id > 0` 隔离。
- 初始化或重建测试库后,再启动或重启 `firstleg-service`。
## 海外仓阶梯费用增量
- `2026-08-05` 增量已追加到固定入口末尾,不创建新的日期部署脚本。
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
## 四期海外权限目录
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。
-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。
-- 只允许写入 baiyue_iam 的权限资源和角色授权;
-- 不建表、不创建账号/客户/仓库,不写业务测试数据。
-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS apply_permission_points;
DELIMITER $$
CREATE PROCEDURE apply_permission_points()
BEGIN
IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0
AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线';
END IF;
IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0
AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在';
END IF;
IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027
AND permission_code <> 'platform:order:declaration:edit')
OR EXISTS (SELECT 1 FROM iam_permission
WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用';
END IF;
START TRANSACTION;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, deleted)
VALUES
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit',
'修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
permission_type = VALUES(permission_type), app_code = VALUES(app_code),
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role_id, 5102027, 0
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0
AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010
AND permission_code = 'platform:order:declaration:edit'
AND status = 'ACTIVE' AND deleted = 0) <> 1
OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission
WHERE tenant_id = 0 AND permission_id = 5102027
AND role_id IN (4000001, 4000003)) <> 2 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入';
END IF;
COMMIT;
END$$
DELIMITER ;
CALL apply_permission_points();
DROP PROCEDURE IF EXISTS apply_permission_points;
SELECT 'PASS' AS permission_initialization_status;
File diff suppressed because one or more lines are too long
+9
View File
@@ -1,5 +1,11 @@
name: firstleg-test-infra name: firstleg-test-infra
x-bounded-logging: &bounded-logging
driver: ${DOCKER_LOG_DRIVER:-json-file}
options:
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services: services:
nacos: nacos:
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2 image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
@@ -22,6 +28,7 @@ services:
retries: 20 retries: 20
start_period: 30s start_period: 30s
restart: unless-stopped restart: unless-stopped
logging: *bounded-logging
networks: [baiyue] networks: [baiyue]
mysql: mysql:
@@ -46,6 +53,7 @@ services:
retries: 20 retries: 20
start_period: 30s start_period: 30s
restart: unless-stopped restart: unless-stopped
logging: *bounded-logging
networks: [baiyue] networks: [baiyue]
redis: redis:
@@ -62,6 +70,7 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
logging: *bounded-logging
networks: [baiyue] networks: [baiyue]
volumes: volumes:
@@ -19,7 +19,7 @@
5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。 5. 平台身份只检查 `app_code=PLATFORM`,部分 Gateway/模块守卫没有再次断言登录主体 `tenant_id=0`;客户身份也应断言 `tenant_id>0`。
6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。 6. 平台代客业务审计当前可能记录平台主体 `tenant_id=0`,导致按目标客户查询审计日志漏记;应同时保留操作者作用域和目标客户归属。
7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。 7. Integration 内部令牌存在可预测默认值,必须改为环境显式注入并保持启动失败保护。
8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,三期存在重新按三级租户开发的返工风险。 8. 模块级 `AGENTS.md`、README、分期文档和 OpenAPI 仍把兼容 `tenant_id` 描述成真实上级租户,国内货柜履约存在重新按三级租户开发的返工风险。
## P2 最小加固 ## P2 最小加固
@@ -63,6 +63,6 @@
- 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。 - 订单履约:`order/`、`firstleg/` 的生产代码与测试,不修改 SQL、OpenAPI、README、AGENTS。
- 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。 - 公共审计事件:`common-audit/`、`audit-service/`、`integration-service/`、`event-bridge-service/`、`common-openapi/` 的生产代码与测试。
- Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。 - Warehouse/File/迁移:`warehouse/`、`file/` 生产代码与测试,以及各模块数据库脚本、`deploy/sql/`、`config/nacos/`;平台公共资源(含往来单位、快递公司)作用域迁移为 `0`,客户私有资源保持客户 ID。
- 文档契约:模块 `AGENTS.md`、README、OpenAPI 和一期/二期/三期指导性文档;不修改 Java 或 SQL。 - 文档契约:模块 `AGENTS.md`、README、OpenAPI 和订单与客户/入库测量与计费/国内货柜履约指导性文档;不修改 Java 或 SQL。
跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。 跨模块新接口、公共 DTO、事件结构和公共仓库作用域变更由主对话统一裁决,单个修改对话不得自行扩展范围。

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