Compare commits

..
218 Commits
Author SHA1 Message Date
ouyangxq 59b42c7579 优化 2026-09-03 11:54:17 +08:00
ouyangxq fd3dbb2fc6 优化 2026-09-03 11:47:28 +08:00
ouyangxq 757f6ef24f 修复头程测量与计费测试门禁 2026-09-03 11:32:08 +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 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 9adbdfbcc1 统一财务事件投递端口 2026-09-02 18:28:39 +08:00
ouyangxq 30090e741f 修复财务事件桥投递地址 2026-09-02 18:24:39 +08:00
ouyangxq 596a5c925c 支持柜费按订单归属分摊 2026-09-02 18:14:25 +08:00
ouyangxq 62855915fc 补齐海外财务权限令牌 2026-09-02 18:09:43 +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 6cd477a037 修正四期全量验收夹具 2026-09-02 16:48:05 +08:00
ouyangxq 03d04431f2 优化 2026-09-02 16:17:41 +08:00
ouyangxq 12fbf2aa67 隔离头程定时任务执行线程 2026-09-02 16:10:52 +08:00
ouyangxq 601ae83504 收敛海外PDA回归夹具选择 2026-09-02 16:01:58 +08:00
ouyangxq 5f688d6131 修正海外PDA实装箱唛验收夹具 2026-09-02 15:47:13 +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 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 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 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 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 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 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 94c67b5c51 优化 2026-09-01 20:41:22 +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 b4e496e9cb fix(file): support optional legal ID and order import files 2026-09-01 19:52:42 +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 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 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 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 ca8127ce83 test(firstleg): formalize late container gate acceptance 2026-09-01 15:53:12 +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
ouyangxq 1e8f504f15 fix(firstleg): allow booking link before container pickup 2026-09-01 15:06:03 +08:00
ouyangxq a10e32a012 test(firstleg): finish task evidence migration 2026-09-01 14:48:51 +08:00
ouyangxq 76d2da5bf9 test(firstleg): use task-scoped loading evidence 2026-09-01 14:46:48 +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 d2092bf0b6 优化 2026-09-01 14:27:04 +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 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
1037 changed files with 36936 additions and 14908 deletions
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.
+15 -3
View File
@@ -22,13 +22,19 @@
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。装柜证据上传重试按幂等键与包含文件内容的请求哈希识别,完全重放不得再次调用 File |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
@@ -40,6 +46,7 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只返回仍有待生成计划入仓码的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
@@ -77,6 +84,7 @@
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
@@ -85,16 +93,20 @@
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
- 脚本管理的唯一总索引是 `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`,不替代客户端全量入口。
- 固定客户待审核订单使用 `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`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
## 阶段文档入口
Vendored
+24 -9
View File
@@ -60,14 +60,15 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS'
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
int markerIndex = description.indexOf(marker)
if (!description?.startsWith('AUTO ')) return null
int markerIndex = description.indexOf(' | ')
if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim()
String state = description.substring(markerIndex + ' | '.length()).trim()
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 ->
int separator = item.indexOf('=')
if (separator > 0) {
@@ -76,6 +77,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
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()]
}
@@ -85,7 +97,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
int inspected = 0
while (candidate != null && inspected < 30) {
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
inspected++
}
@@ -190,9 +203,10 @@ pipeline {
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
// SELECT/ALL 不会推进 AUTO 基线。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
}
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules
@@ -295,6 +309,7 @@ pipeline {
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
+16
View File
@@ -33,6 +33,22 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
国内 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` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
+1
View File
@@ -2,6 +2,7 @@
## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
@@ -1,6 +1,7 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan("com.baiyue.audit.mapper")
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
@@ -1,10 +1,13 @@
package com.baiyue.audit.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity;
/**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
}
@@ -40,14 +40,3 @@ baiyue:
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
+4 -1
View File
@@ -2,6 +2,7 @@
## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
@@ -13,9 +14,10 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.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 与会话 | `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 所需的 `platform:finance:*` 及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
@@ -28,6 +30,7 @@
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
## 全量验收导航
@@ -1,5 +1,6 @@
package com.baiyue.auth;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
*/
@MapperScan("com.baiyue.auth.mapper")
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueAuthApplication {
@@ -22,7 +22,7 @@ public interface IamOnboardingClient {
@PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail,
String legalPerson, String administratorPhone, String contactEmail,
String countryCode, String address) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
@@ -77,7 +77,7 @@ public class AuthController {
@SecurityRequirements
@PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request));
}
@@ -85,6 +85,20 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
}
@PutMapping("/{id}/login-identifiers")
public ApiResponse<AccountSummary> loginIdentifiers(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
verify(token);
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
}
@PutMapping("/{id}/phone")
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
verify(token);
service.updatePhone(id, request);
return ApiResponse.success(null);
}
@PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId,
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) {
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
}
}
public record ChangeLoginIdentifiersRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank String currentPassword) { }
public record UpdateAccountPhoneRequest(
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest(
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status,
public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) {
this(accountId, username, displayName, status, null, null, null, null);
this(accountId, username, null, displayName, status, null, null, null, null);
}
}
}
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
*/
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
public record LoginRequest(
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
@Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
@NotBlank @Size(max = 64) String loginId,
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
) {
}
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
public record MerchantRegisterRequest(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson,
@NotBlank @Size(max = 32) String legalPhone,
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
public class AuthAccount {
@TableId private Long id;
private String username;
private String phone;
private String passwordHash;
private String displayName;
private String accountStatus;
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthAccount;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
*/
@Mapper
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthRefreshToken;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
*/
@Mapper
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
.last("LIMIT 1"));
}
public AuthAccount findByPhone(String phone) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.last("LIMIT 1"));
}
public boolean existsByUsername(String username) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)) > 0;
}
public boolean existsByPhone(String phone) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)) > 0;
}
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)
.ne(AuthAccount::getId, accountId)) > 0;
}
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.ne(AuthAccount::getId, accountId)) > 0;
}
public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) {
@@ -71,9 +71,13 @@ public class AccountManagementService {
if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
}
if (request.phone() != null && repository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
AuthAccount account = new AuthAccount();
account.setId(request.accountId());
account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
@@ -117,6 +121,45 @@ public class AccountManagementService {
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改本人用户名和手机号", captureAfter = false)
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
AuthAccount account = required(accountId);
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
}
String username = request.username().trim();
String phone = request.phone().trim();
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
return summary(account);
}
account.setUsername(username);
account.setPhone(phone);
bumpVersion(account);
return summary(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步受管账号手机号", captureAfter = false)
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
AuthAccount account = required(accountId);
String phone = request.phone() == null ? null : request.phone().trim();
if (Objects.equals(account.getPhone(), phone)) return;
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
account.setPhone(phone);
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
@@ -185,7 +228,7 @@ public class AccountManagementService {
}
private AccountSummary summary(AuthAccount a) {
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
}
@@ -201,6 +244,7 @@ public class AccountManagementService {
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
@@ -57,10 +57,13 @@ public class AuthService {
@Transactional
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) {
AuthAccount account = accountRepository.findByUsername(request.username());
String loginId = request.loginId().trim();
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
? accountRepository.findByPhone(loginId)
: accountRepository.findByUsername(loginId);
if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus()))
@@ -211,7 +214,7 @@ public class AuthService {
}
private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
}
}
@@ -36,7 +36,10 @@ public class MerchantRegistrationService {
resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "Username already exists");
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (accountRepository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
@@ -46,7 +49,7 @@ public class MerchantRegistrationService {
try {
var response = onboardingClient.register(internalToken,
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(),
request.companyName(), request.legalPerson(), request.phone(),
request.contactEmail(), request.countryCode(), request.address()));
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
@@ -81,6 +84,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount();
account.setId(accountId);
account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
@@ -94,7 +98,7 @@ public class MerchantRegistrationService {
try {
accountRepository.insert(account);
} catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
@@ -31,6 +31,7 @@ public class OverseasTokenService {
"platform:overseas-warehouse:",
"platform:overseas-business:",
"platform:overseas-master-data:",
"platform:finance:",
"lastleg:"
);
-11
View File
@@ -52,14 +52,3 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,43 @@
-- 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
IF EXISTS (
SELECT user.phone
FROM baiyue_iam.iam_user user
WHERE user.deleted = 0 AND user.phone REGEXP '^1[3-9][0-9]{9}$'
GROUP BY user.phone HAVING COUNT(DISTINCT user.auth_user_id) > 1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'IAM 存在重复手机号,禁止建立 Auth 唯一登录索引';
END IF;
UPDATE auth_account account
JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
SET account.phone = user.phone
WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$';
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
AND index_name = 'uk_auth_account_phone') THEN
ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
END IF;
END$$
DELIMITER ;
CALL auth_backfill_login_phone();
DROP PROCEDURE auth_backfill_login_phone;
+2
View File
@@ -6,6 +6,7 @@ USE baiyue_auth;
CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username),
UNIQUE KEY uk_auth_account_phone (phone),
KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
@@ -0,0 +1,48 @@
package com.baiyue.auth.repository;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.dto.MerchantRegisterRequest;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class LoginIdentifierContractTest {
@Test
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
.map(component -> component.getName())
.toList();
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
}
@Test
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var invalidRegistration = new MerchantRegisterRequest(
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
"11111111111", null, "CN", null);
var validChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "13800138000", "Password1");
var invalidChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "11111111111", "Password1");
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
assertThat(validator.validate(validChange)).isEmpty();
assertThat(validator.validate(invalidChange)).isNotEmpty();
}
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("REGEXP '^1[3-9][0-9]{9}$'")
.doesNotContain("REGEXP '^1[0-9]{10}$'");
}
}
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
@@ -157,6 +159,44 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L);
}
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
var result = service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
assertEquals("new.user", result.username());
assertEquals("13900139000", result.phone());
assertEquals(3, account.getTokenVersion());
verify(sessions).revokeAll(10L);
}
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
account.setPhone("13800138000");
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
assertEquals(null, account.getPhone());
assertEquals(3, account.getTokenVersion());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
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);
when(repository.findByUsername("operator")).thenReturn(account);
@@ -91,7 +91,7 @@ class AuthServiceSmallJwtTest {
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("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(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
@@ -100,6 +100,46 @@ class AuthServiceSmallJwtTest {
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
@Test
void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
account.setPhone("13800138000");
account.setPasswordHash("hash");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(0L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(84L);
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of(), Set.of(), 1L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByPhone("13800138000")).thenReturn(account);
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
service.login(new LoginRequest("13800138000", "secret"));
verify(repository).findByPhone("13800138000");
verify(repository, never()).findByUsername("13800138000");
}
@Test
void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() {
when(repository.findByUsername("11111111111")).thenReturn(null);
assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret")))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("登录标识或密码错误");
verify(repository).findByUsername("11111111111");
verify(repository, never()).findByPhone("11111111111");
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
@@ -54,6 +54,8 @@ class MerchantRegistrationServiceTest {
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("13800138000", storedAccount.getValue().getPhone());
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus());
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
}
private void inject(String fieldName, Object value) throws Exception {
@@ -50,9 +50,11 @@ class OverseasTokenServiceTest {
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
String authority = "platform:overseas-master-data:quote:list";
String lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
Set.of(authority, lastlegAuthority, financeAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -74,8 +76,8 @@ class OverseasTokenServiceTest {
.containsEntry("permission_version", 7L)
.containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
.containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority, financeAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
}
@@ -87,9 +89,9 @@ class OverseasTokenServiceTest {
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second);
@@ -117,7 +119,7 @@ class OverseasTokenServiceTest {
AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
+1 -1
View File
@@ -17,8 +17,8 @@
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `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 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
@@ -0,0 +1,52 @@
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 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;
}
}
@@ -13,7 +13,7 @@ public final class EventTypes {
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -1,5 +1,6 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
String queryId,
String customerId,
String oceanBookingId,
String containerBookingId,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo,
String standardNode,
int nodeSequence,
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|| containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -1,13 +1,38 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV2 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
Order owner = orders.get(0);
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|| !owner.customerName().equals(order.customerName()))) {
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
}
}
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
customerId = customerId.trim();
customerName = customerName.trim();
if (customerId.length() > 128 || customerName.length() > 128) {
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +82,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -86,7 +127,7 @@ public record FirstLegLoadingPlanCompletedV2(
}
}
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
Instant unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt
) {
public OverseasContainerUnloadedV1 {
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
throw new IllegalArgumentException("sourceContainerBookingId is required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
}
}
@@ -1,17 +0,0 @@
package com.baiyue.common.event;
import java.time.Instant;
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
public record OverseasInboundOrderDetainedChangedV1(
String sourceInboundId,
String inboundCode,
String lastlegOrderId,
String lastlegOrderNo,
boolean detained,
String reason,
String operatorId,
String operatorName,
Instant changedAt
) {
}
@@ -0,0 +1,68 @@
openapi: 3.1.0
info:
title: Baiyue Cross-region Event Contracts
version: 1.0.0
paths: {}
components:
schemas:
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: {type: object}}
destinationType: {type: string, enum: [FBA, OVERSEAS]}
FirstLegContainerShippingNodeRecordedV1:
type: object
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
properties:
queryId: {type: string}
customerId: {type: string}
oceanBookingId: {type: string}
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}$'}
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,6 +21,18 @@ class FirstLegContainerShippingNodeRecordedV1Test {
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80);
var json = mapper.valueToTree(payload);
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
assertThat(json.has("containerBookingId")).isFalse();
}
@Test
void rejectsNonCanonicalPhysicalContainerNumber() {
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
"1", "2", "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
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
new BigDecimal("0.200"), List.of(item));
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
assertThat(restored.actualPortArrivalAt()).isNull();
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
}
}
@@ -4,18 +4,59 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
assertThat(json.has("containerBookingId")).isFalse();
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
}
@Test
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("normalized");
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
.hasMessageContaining("containerNo");
}
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
null, null, null, 1, 2, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
@@ -29,13 +70,14 @@ class FirstLegLoadingPlanCompletedV2Test {
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
@@ -44,10 +86,10 @@ class FirstLegLoadingPlanCompletedV2Test {
void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
}
@@ -58,12 +100,55 @@ class FirstLegLoadingPlanCompletedV2Test {
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
null, null, null, 2, 20, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "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/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");
}
}
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
}
@Test
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
.hasMessageContaining("normalized");
}
}
+4
View File
@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -96,6 +98,24 @@ public class GlobalExceptionHandler {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(CannotAcquireLockException.class)
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
CannotAcquireLockException exception, HttpServletRequest request) {
log.warn("Database lock acquisition conflict requestId={} traceId={}",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(
MaxUploadSizeExceededException exception, HttpServletRequest request) {
long maxBytes = exception.getMaxUploadSize();
String message = maxBytes > 0
? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB"
: "上传文件超过服务允许大小";
return failure(ResultCode.BAD_REQUEST, message, request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -16,6 +16,8 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.lang.reflect.Method;
@@ -116,6 +118,32 @@ class GlobalExceptionHandlerTest {
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) {
}
}
-3
View File
@@ -50,9 +50,6 @@ spring:
client.config.default:
connect-timeout: 3000
read-timeout: 5000
logging:
file:
path: ${LOG_PATH:/app/logs}
server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
+1
View File
@@ -11,3 +11,4 @@ baiyue:
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events}
+20
View File
@@ -22,6 +22,26 @@ baiyue:
max-attempts: 3
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:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+4
View File
@@ -4,6 +4,10 @@ spring:
username: ${FIRSTLEG_DB_USERNAME}
password: ${FIRSTLEG_DB_PASSWORD}
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.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
-11
View File
@@ -10,19 +10,8 @@
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1
```
也可以使用统一入口;`local` 会被强制限制在 `public` namespace:
```powershell
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local
```
配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。
本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括
`gateway-overseas.yml`、`lastleg-service.yml` 和
`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机
Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。
## MyBatis SQL 日志
本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。
-3
View File
@@ -47,9 +47,6 @@ spring:
client.config.default:
connect-timeout: 3000
read-timeout: 5000
logging:
file:
path: ${LOG_PATH:./logs}
server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
@@ -5,7 +5,5 @@ spring.datasource:
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
baiyue:
region: ${REGION_CODE:CN}
event-bridge:
accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true}
deployment-cell: ${DEPLOYMENT_CELL:local-cn}
data-residency: ${DATA_RESIDENCY:CN}
+20
View File
@@ -18,6 +18,26 @@ baiyue:
max-attempts: 3
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:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+2
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}
username: ${FIRSTLEG_DB_USERNAME:root}
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.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
-16
View File
@@ -1,16 +0,0 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:true}
swagger-ui.enabled: ${SWAGGER_ENABLED:true}
baiyue:
security:
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
pda-session-validation:
auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s}
overseas-projection:
service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s}
-29
View File
@@ -1,29 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${LASTLEG_DB_USERNAME:root}
password: ${LASTLEG_DB_PASSWORD:123456}
lastleg:
dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
file:
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
warehouse:
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}
firstleg:
event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -1,19 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:123456}
baiyue:
clients:
domestic-warehouse:
url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
internal-service:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+1 -15
View File
@@ -1,19 +1,5 @@
# 海外 Nacos 配置
本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
发布示例:
```powershell
# 开发环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment development -Region overseas -ServerAddr <dev-nacos> -Namespace <dev-overseas-namespace>
# 测试环境
powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 `
-Environment test -Region overseas -ServerAddr <test-nacos> -Namespace <test-overseas-namespace>
```
远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。
@@ -1,4 +1,8 @@
spring:
servlet:
multipart:
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
datasource:
url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME}
+4 -8
View File
@@ -24,13 +24,13 @@ x-overseas-common: &overseas-common
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
DATA_RESIDENCY: OVERSEAS
SPRING_SQL_INIT_MODE: never
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
driver: ${DOCKER_LOG_DRIVER:-json-file}
options:
max-size: 100m
max-file: "5"
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services:
gateway-overseas:
@@ -38,7 +38,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9300
@@ -55,7 +54,6 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9303
@@ -73,7 +71,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9305
@@ -87,7 +84,6 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9312
+9
View File
@@ -1,10 +1,17 @@
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:
rocketmq-namesrv:
image: apache/rocketmq:5.3.1
restart: unless-stopped
command: sh mqnamesrv
logging: *bounded-logging
networks: [infra-network]
rocketmq-broker:
@@ -17,6 +24,7 @@ services:
- ./rocketmq:/config:ro
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
command: sh /config/render-broker-config.sh
logging: *bounded-logging
networks: [infra-network]
rocketmq-topic-init:
@@ -46,6 +54,7 @@ services:
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
restart: on-failure
logging: *bounded-logging
networks: [infra-network]
networks:
+5 -16
View File
@@ -26,13 +26,13 @@ x-common: &common
REGION_CODE: ${REGION_CODE:-CN}
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
driver: ${DOCKER_LOG_DRIVER:-json-file}
options:
max-size: 100m
max-file: "5"
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services:
gateway:
@@ -40,7 +40,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9000
@@ -53,7 +52,6 @@ services:
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9100
@@ -72,7 +70,6 @@ services:
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9201
@@ -86,7 +83,6 @@ services:
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9202
@@ -112,7 +108,6 @@ services:
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9203
@@ -125,7 +120,6 @@ services:
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9204
@@ -138,7 +132,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9205
@@ -153,7 +146,6 @@ services:
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
environment:
<<: *common-environment
SERVER_PORT: 9206
@@ -175,7 +167,6 @@ services:
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9207
@@ -186,7 +177,6 @@ services:
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9210
@@ -236,7 +226,6 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9312
@@ -245,6 +234,7 @@ services:
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events}
SPRING_SQL_INIT_MODE: never
networks:
firstleg-network:
@@ -256,7 +246,6 @@ services:
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-audit
expose: ["9700"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9700
+1 -1
View File
@@ -15,7 +15,7 @@ RUN addgroup -S app \
WORKDIR /app
# 预建镜像内运行时数据和日志目录并归属 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。
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
+30 -12
View File
@@ -4,36 +4,54 @@
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>
<id>baiyue-nexus-public</id>
<name>柏悦 Nexus Maven 聚合仓库</name>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>external:*</mirrorOf>
</mirror>
</mirrors>
<servers>
<server>
<id>baiyue-nexus-public</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-releases</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-snapshots</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
</servers>
<profiles>
<profile>
<id>国内公共镜像</id>
<id>柏悦 Nexus</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内公共镜像</activeProfile>
<activeProfile>柏悦 Nexus</activeProfile>
</activeProfiles>
</settings>
File diff suppressed because it is too large Load Diff
+81 -39
View File
@@ -195,7 +195,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价',
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细',
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商',
domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商',
overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
@@ -211,8 +211,6 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
) ENGINE=InnoDB COMMENT='海外采购订单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity'));
PREPARE procurement_outbound_stmt FROM @ddl; EXECUTE procurement_outbound_stmt; DEALLOCATE PREPARE procurement_outbound_stmt;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
@@ -493,6 +491,24 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'),
'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container','SELECT 1'));
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'),
'SELECT 1','ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
DROP PROCEDURE IF EXISTS assert_container_progress_index_ready;
DELIMITER $$
CREATE PROCEDURE assert_container_progress_index_ready()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_order' AND index_name='idx_ll_order_container_progress') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外区域初始化失败:柜级入库计划进度索引缺失';
END IF;
END$$
DELIMITER ;
CALL assert_container_progress_index_ready();
DROP PROCEDURE assert_container_progress_index_ready;
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
@@ -500,6 +516,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
@@ -581,7 +603,8 @@ CALL assert_pending_orders_do_not_use_source_warehouse();
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
@@ -592,6 +615,13 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id'));
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id'));
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
@@ -747,6 +777,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name'),
'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
'ALTER TABLE ll_inventory_balance ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_name'),
'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
'ALTER TABLE ll_inventory_ledger ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
@@ -1035,42 +1073,7 @@ DELIMITER ;
CALL assert_phase4_pda_operations_ready();
DROP PROCEDURE assert_phase4_pda_operations_ready;
CREATE TABLE IF NOT EXISTS ll_order_fee (
id BIGINT NOT NULL COMMENT '费用明细主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', fee_item VARCHAR(128) NOT NULL COMMENT '费用项目',
currency VARCHAR(3) NOT NULL COMMENT '币种代码', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回',
supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', created_by VARCHAR(128) NOT NULL COMMENT '创建操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单费用明细';
CREATE TABLE IF NOT EXISTS ll_order_operation_log (
id BIGINT NOT NULL COMMENT '操作记录主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
order_id BIGINT NOT NULL COMMENT '尾程订单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点',
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version'
), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at'));
PREPARE ll_inbound_plan_version_ddl FROM @ddl;
EXECUTE ll_inbound_plan_version_ddl;
DEALLOCATE PREPARE ll_inbound_plan_version_ddl;
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'
), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag'));
PREPARE ll_order_merge_batch_ddl FROM @ddl;
EXECUTE ll_order_merge_batch_ddl;
DEALLOCATE PREPARE ll_order_merge_batch_ddl;
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
@@ -1113,4 +1116,43 @@ END$$
DELIMITER ;
CALL assert_lastleg_security_projection_ready();
DROP PROCEDURE assert_lastleg_security_projection_ready;
CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID',
source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键',
customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型',
source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型',
business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号',
order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID',
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED'))
) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
+9
View File
@@ -1,5 +1,11 @@
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:
nacos:
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
@@ -22,6 +28,7 @@ services:
retries: 20
start_period: 30s
restart: unless-stopped
logging: *bounded-logging
networks: [baiyue]
mysql:
@@ -46,6 +53,7 @@ services:
retries: 20
start_period: 30s
restart: unless-stopped
logging: *bounded-logging
networks: [baiyue]
redis:
@@ -62,6 +70,7 @@ services:
timeout: 5s
retries: 10
restart: unless-stopped
logging: *bounded-logging
networks: [baiyue]
volumes:
+53 -49
View File
@@ -1,62 +1,66 @@
# Nacos 环境切换说明
# 数据库环境切换说明
数据库、Redis 和服务地址均由所选环境的 Nacos 配置决定。本地启动时只切换
Nacos 地址与 namespace,不再通过 `application-test.yml` 单独切换数据库。
## 环境定义
## 环境映射
- `dev`:本地开发环境,连接 `127.0.0.1:3306`,默认账号为 `root/123456`。
- `test`:联调测试环境,连接 `8.163.30.128:3306`,各微服务仍使用独立数据库。
| Maven Profile | Nacos 地址 | Namespace |
| --- | --- | --- |
| `local` | `127.0.0.1:8848` | `public` |
| `dev` | `dev-nacos.buggoglobal.com:8848` | `DEV_NACOS_NAMESPACE` |
| `test` | `8.163.30.128:8848` | `d99d7998-5bb0-4ffe-a2a0-1a564ce14463` |
所有服务默认使用 `dev`:
本地 Nacos 使用 `nacos/nacos`。开发和测试密码不写入 Git,需在启动 IDEA
之前设置一次用户环境变量:
```powershell
[Environment]::SetEnvironmentVariable('DEV_NACOS_PASSWORD', '<开发 Nacos 密码>', 'User')
[Environment]::SetEnvironmentVariable('TEST_NACOS_PASSWORD', '<测试 Nacos 密码>', 'User')
[Environment]::SetEnvironmentVariable('DEV_NACOS_NAMESPACE', '<开发海外 namespace>', 'User')
```yaml
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
```
设置后完全退出并重新打开 IDEA,使 Maven 能读取新环境变量。
建议不要直接修改配置文件。通过 IDEA 的启动配置设置 `Active profiles` 即可切换:
## IDEA 中切换
- 本地数据库:`dev`
- 测试数据库:`test`
1. 在 Maven 面板的 `Profiles` 中,`local`、`dev`、`test` 只勾选一个。
2. 重新加载 Maven 项目。
3. 在目标服务的 Maven `Plugins > spring-boot > spring-boot:run` 启动。
也可以使用启动参数:
例如海外查询链路需要分别启动 `gateway-overseas`、`lastleg-service` 和
`warehouse-overseas-service`。已经运行的进程不会自动切换环境,切换 Profile
后必须停止并重新启动。
IDEA 普通绿色 Run 按钮不执行 Maven,因此不会读取 Maven Profile。需要继续使用
绿色 Run 时,请在 Run Configuration 的 Environment variables 中设置
`NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_USERNAME` 和 `NACOS_PASSWORD`。
`gateway-overseas` 已默认启用 `overseas` Spring Profile。
## PowerShell 中切换
也可以在当前终端加载同一套变量,然后从同一终端启动服务:
```powershell
. .\scripts\use-nacos-environment.ps1 -Environment local -Region overseas
. .\scripts\use-nacos-environment.ps1 -Environment dev -Region overseas
. .\scripts\use-nacos-environment.ps1 -Environment test -Region overseas
```text
--spring.profiles.active=test
```
脚本读取 `DEV_NACOS_PASSWORD`、`TEST_NACOS_PASSWORD`,namespace 优先读取
`*_OVERSEAS_NACOS_NAMESPACE`,未设置时读取 `*_NACOS_NAMESPACE`。命令前面的点和
空格不能省略,否则变量只存在于脚本子进程中。
## 测试数据库密码
## 当前限制
测试数据库密码不写入 Git。启动 `test` 环境前,在 IDEA 启动配置的环境变量中设置:
- 测试 Nacos 的 `8848` 客户端端口已确认可连接。
- 开发 Nacos 网页控制台可访问,但 `dev-nacos.buggoglobal.com:8848` 当前从本机
无法连接。需要开放 Nacos 客户端端口以及 Nacos 2.x/3.x 所需的 gRPC 端口,
或提供一个客户端可访问的内部地址。
- 国内和海外存在同名 Data ID,远程环境必须使用各自独立 namespace。开发海外
namespace 尚未确认,因此通过 `DEV_NACOS_NAMESPACE` 在本机配置。
- 不要同时勾选多个环境 Profile;Maven 对同名属性的最终取值不直观,容易连错环境。
```text
TEST_DB_PASSWORD=测试数据库密码
```
公共测试连接参数支持覆盖:
```text
TEST_DB_HOST=8.163.30.128
TEST_DB_PORT=3306
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=测试数据库密码
```
每个服务还支持使用自己的 `*_TEST_DB_URL`、`*_TEST_DB_USERNAME` 和 `*_TEST_DB_PASSWORD` 覆盖公共参数。
## 分库映射
| 服务 | 测试数据库 |
| --- | --- |
| 认证服务 | `baiyue_auth` |
| 权限服务 | `baiyue_iam` |
| 订单服务 | `baiyue_order` |
| 仓库服务 | `baiyue_warehouse` |
| 头程履约服务 | `baiyue_firstleg` |
| 文件服务 | `baiyue_file` |
| 审计服务 | `baiyue_audit` |
| 事件桥接服务 | `baiyue_event_bridge` |
网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
## 注意事项
- `test` 只切换数据库,Nacos 和 Redis 默认仍连接本机。
- 环境变量优先级高于配置文件,可用于临时覆盖单个服务。
- 修改 IDEA 环境变量后需要重启对应服务。
- 不要把测试数据库密码提交到配置文件、启动脚本或 Git。
+3 -1
View File
@@ -2,6 +2,7 @@
## 职责边界
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
@@ -12,9 +13,10 @@
|---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
| 国内 Finance 费用事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;统一内部入口 `/internal/finance/v1/business-fee-source-events`,配置 `baiyue.event-dispatcher.finance-consumer-url`,不得误投 Firstleg |
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
@@ -1,6 +1,7 @@
package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan("com.baiyue.eventbridge.mapper")
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
@@ -30,7 +30,7 @@ public class EventBridgeMetrics {
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
EventTypes.IAM_USER_STATUS_CHANGED,
EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
@@ -31,14 +31,10 @@ public class EventInboxService {
@Value("${baiyue.region:CN}")
private String localRegion;
/** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */
@Value("${baiyue.event-bridge.accept-any-region:false}")
private boolean acceptAnyRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
envelope.requireValid();
if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(
@@ -49,6 +45,11 @@ public class EventInboxService {
if (existing != null) {
return duplicateOrConflict(existing, envelope);
}
String idempotencyKey = idempotencyKey(envelope);
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (sameBusinessEvent != null) {
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
}
BridgeInbox inbox = toInbox(envelope);
try {
inboxStore.insert(inbox);
@@ -56,13 +57,25 @@ public class EventInboxService {
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (concurrent == null) {
throw exception;
}
return duplicateOrConflict(concurrent, envelope);
return Objects.equals(concurrent.getEventId(), envelope.eventId())
? duplicateOrConflict(concurrent, envelope)
: duplicateBusinessEventOrConflict(concurrent, envelope);
}
}
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameBusinessEvent(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
}
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
}
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameEnvelope(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
@@ -73,6 +86,13 @@ public class EventInboxService {
}
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
return sameBusinessEvent(existing, envelope)
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
}
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
return existing.getPayloadHash() != null
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
&& Objects.equals(existing.getEventType(), envelope.eventType())
@@ -84,16 +104,13 @@ public class EventInboxService {
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId());
}
private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId());
inbox.setIdempotencyKey(idempotencyKey(envelope));
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
@@ -121,4 +138,8 @@ public class EventInboxService {
private static Instant normalizeOccurredAt(Instant value) {
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
}
private static String idempotencyKey(EventEnvelope envelope) {
return envelope.sourceRegion() + ":" + envelope.correlationId();
}
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FinanceEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -0,0 +1,56 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFinanceEventClient implements FinanceEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}")
private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(consumerUrl)
.timeout(properties.requestTimeout()).header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
try { body = objectMapper.readTree(response.body()); }
catch (Exception ignored) { body = null; }
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode()
+ ", code=" + (body == null ? "missing" : body.path("code").asText("missing")));
}
return String.valueOf(response.statusCode());
}
}
@@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
*/
public interface InboxStore {
BridgeInbox findByEventId(String eventId);
BridgeInbox findByIdempotencyKey(String idempotencyKey);
void insert(BridgeInbox inbox);
}
@@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
@@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore {
return mapper.selectById(eventId);
}
@Override
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
}
@Override
public void insert(BridgeInbox inbox) {
mapper.insert(inbox);
@@ -27,6 +27,7 @@ public class OverseasEventDispatcher {
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient;
@Resource private FinanceEventClient financeEventClient;
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeMetrics metrics;
@@ -89,7 +90,8 @@ public class OverseasEventDispatcher {
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
}
return switch (event.getTargetRegion()) {
case "CN" -> firstlegEventClient.deliver(event);
case "CN" -> EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(eventType)
? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
@@ -29,10 +29,11 @@ spring:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:CN}
region: ${REGION_CODE:OVERSEAS}
event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
CREATE TABLE IF NOT EXISTS bridge_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键',
idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
@@ -106,6 +106,32 @@ class EventInboxServiceTest {
.hasMessageContaining("事件信封");
}
@Test
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(null);
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
assertThat(service.receive(replay).result()).isEqualTo("重复");
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
driftPayload.put("containerNo", "DIFFERENT");
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
null, driftPayload, driftHash);
assertThatThrownBy(() -> service.receive(drift))
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
}
@Test
void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
@@ -125,15 +151,6 @@ class EventInboxServiceTest {
.hasMessageContaining("目标区域");
}
@Test
void 本地单实例模式允许接收不同目标区域事件() {
EventInboxService localService = service("CN", true);
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThat(localService.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class));
}
private EventEnvelope envelope(String hash) {
return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1,
Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS,
@@ -142,16 +159,11 @@ class EventInboxServiceTest {
}
private EventInboxService service(String region) {
return service(region, false);
}
private EventInboxService service(String region, boolean acceptAnyRegion) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "metrics", metrics);
ReflectionTestUtils.setField(service, "localRegion", region);
ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion);
return service;
}
@@ -31,6 +31,7 @@ class OverseasEventDispatcherTest {
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class);
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
@@ -48,6 +49,7 @@ class OverseasEventDispatcherTest {
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient);
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
@@ -150,6 +152,22 @@ class OverseasEventDispatcherTest {
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception {
event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
event.setSourceRegion("OVERSEAS");
event.setTargetRegion("CN");
when(financeEventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(financeEventClient).deliver(event);
verifyNoInteractions(firstlegEventClient);
verifyNoInteractions(eventClient);
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
+9 -3
View File
@@ -2,6 +2,7 @@
## 职责与边界
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
@@ -9,12 +10,15 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java`、`controller/QualificationTypes.java`;契约 `src/main/resources/openapi/qualification-required-package-v1.yaml`。营业执照必须与 18 位统一社会信用代码走 `required-package`;法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换 |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -23,10 +27,12 @@
## 修改约束
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
## 全量验收导航
@@ -1,5 +1,7 @@
package com.baiyue.file;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@@ -9,6 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
* 文件中心服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.file.client")
@EnableScheduling
public class FileServiceApplication {
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
check(token);
checkTenantId(tenantId);
var value = service.download(tenantId, businessType, businessId, fileId);
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", "");
MediaType contentType;
try {
contentType = MediaType.parseMediaType(value.contentType());
} catch (IllegalArgumentException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length)
.body(value.content());
}
@@ -25,32 +25,32 @@ public class InternalLoadingEvidenceController {
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0");
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@GetMapping("/{containerBookingId}/{fileId}")
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
@PathVariable long containerBookingId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
return ApiResponse.success(service.validate(containerBookingId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
@GetMapping("/{containerBookingId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
@PathVariable long containerBookingId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
var value = service.download(tenantId, inboundId, fileId);
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
var value = service.download(containerBookingId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
@@ -0,0 +1,59 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.InternalBusinessFileService;
import jakarta.annotation.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/merchant/v1/files/order-batch-imports")
public class MerchantOrderBatchImportFileController {
private static final String ERROR_REPORT = "ORDER_BATCH_IMPORT_ERROR_REPORT";
@Resource private InternalBusinessFileService files;
@GetMapping("/{batchNo}/error-reports/{fileId}")
@PreAuthorize("hasAuthority('merchant:order:create')")
public ResponseEntity<byte[]> downloadErrorReport(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String batchNo,
@PathVariable long fileId) {
var value = files.download(merchantTenant(user), ERROR_REPORT, batchNo, fileId);
MediaType contentType;
try {
contentType = MediaType.parseMediaType(value.contentType());
} catch (IllegalArgumentException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length)
.body(value.content());
}
private static long merchantTenant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
}
return user.tenantId();
}
}
@@ -20,21 +20,21 @@ import java.nio.charset.StandardCharsets;
public class PlatformLoadingEvidenceController {
@Resource private LoadingEvidenceService service;
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long containerBookingId,
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
long operatorId = requirePlatform(user); requirePositive(containerBookingId);
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@GetMapping("/{containerBookingId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
@PathVariable long containerBookingId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(containerBookingId, fileId);
var value = service.download(containerBookingId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
@@ -87,8 +87,8 @@ public class PlatformQualificationFileController {
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@RequestPart("businessLicense") MultipartFile businessLicense,
@RequestPart("legalPersonId") MultipartFile legalPersonId,
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo,
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
@@ -40,7 +40,7 @@ public class QualificationFileController {
@RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) {
long tenantId = merchantTenant(user);
qualificationType = QualificationTypes.requireOptional(qualificationType);
qualificationType = QualificationTypes.requireSingleUpload(qualificationType);
var stored = storage.store(tenantId, file);
try {
var response = iamClient.submit(internalToken,
@@ -78,8 +78,8 @@ public class QualificationFileController {
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("businessLicense") MultipartFile businessLicense,
@RequestPart("legalPersonId") MultipartFile legalPersonId,
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo,
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
@@ -21,11 +21,22 @@ final class QualificationTypes {
return qualificationType;
}
static String requireSingleUpload(String qualificationType) {
String type = requireSupported(qualificationType);
if ("BUSINESS_LICENSE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST,
"The business license must be uploaded with its unified social credit code"
+ " / 营业执照必须与统一社会信用代码一并上传");
}
return type;
}
static String requireOptional(String qualificationType) {
String type = requireSupported(qualificationType);
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST,
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
"Business license and legal-person ID use the platform qualification package"
+ " / 平台端营业执照与法人身份证通过资质包上传");
}
return type;
}
@@ -19,6 +19,7 @@ public final class ExportTaskDtos {
String id, String taskNo, String exportType, String format, String taskName,
String status, Long totalRows, long processedRows, String fileName,
String contentType, Long sizeBytes, String failureMessage,
int attemptCount, Integer maxAttempts, LocalDateTime nextRetryAt,
String requestedBy, String requestedUsername, LocalDateTime requestedAt,
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
}
@@ -33,6 +33,13 @@ public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
int sendFailed(@Param("id") long id, @Param("owner") String owner,
@Param("backoffSeconds") int backoffSeconds, @Param("error") String error);
@Update("""
UPDATE file_export_outbox SET next_retry_at=CURRENT_TIMESTAMP(3)
WHERE task_id=#{taskId} AND dispatch_version=#{dispatchVersion}
AND status='PENDING' AND next_retry_at>CURRENT_TIMESTAMP(3)
""")
int expeditePendingRetry(@Param("taskId") long taskId, @Param("dispatchVersion") int dispatchVersion);
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50")
int releaseExpiredClaims();

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