Compare commits

..
269 Commits
Author SHA1 Message Date
liuyihe 6b077c20db 暂存提交 2026-08-19 09:28:40 +08:00
liuyihe 0192380e81 暂存提交 2026-08-14 15:15:43 +08:00
liuyihe 8540147992 Merge branch 'test' into test-lastlag
# Conflicts:
#	auth/src/main/java/com/baiyue/auth/service/AuthService.java
#	auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
2026-08-14 09:24:12 +08:00
ouyangxq db80701534 优化 2026-08-14 09:14:52 +08:00
ouyangxq 274a3f34c5 优化 2026-08-13 20:00:31 +08:00
ouyangxq 5bc62a109e 优化 2026-08-13 20:00:30 +08:00
liuyihe af646c6f1f 暂存提交 2026-08-13 19:49:19 +08:00
ouyangxq 50251240f4 优化 2026-08-13 19:23:44 +08:00
ouyangxq 808fabaf41 优化 2026-08-13 19:19:29 +08:00
liuyihe 073cb6a577 暂存提交 2026-08-13 19:01:16 +08:00
ouyangxq cca1bc002e 优化 2026-08-13 19:00:33 +08:00
ouyangxq 8ba04fc87d 优化 2026-08-13 18:35:19 +08:00
ouyangxq 8cf6709bde 优化 2026-08-13 18:34:44 +08:00
ouyangxq f779ab56dd fix: restore shared HS256 authentication 2026-08-13 17:58:26 +08:00
ouyangxq 9eaaa9a0a0 ci: streamline test environment deployments 2026-08-13 17:40:55 +08:00
ouyangxq 8ff49b501f fix: handle unfiltered domestic container lists 2026-08-13 17:30:03 +08:00
ouyangxq 6ad438e733 优化 2026-08-13 17:24:40 +08:00
ouyangxq 885ff9dc9e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 17:14:54 +08:00
ouyangxq e9564a21a2 优化 2026-08-13 17:14:46 +08:00
liuyihe c43d581ba6 打印箱唛修改 尾程垮桥事件提交 2026-08-13 17:13:58 +08:00
ouyangxq b004edaf9e 1 2026-08-13 17:01:34 +08:00
ouyangxq b780d8bad2 1 2026-08-13 17:01:30 +08:00
ouyangxq 38e8494216 优化 2026-08-13 16:58:17 +08:00
ouyangxq bd523036d0 fix: add inbound fee partner snapshot columns 2026-08-13 16:41:59 +08:00
ouyangxq 6d10f9e502 fix: remove duplicate service center permission counts 2026-08-13 16:23:58 +08:00
ouyangxq 226ef6a839 优化 2026-08-13 16:14:54 +08:00
ouyangxq 8bcd23f307 优化 2026-08-13 15:59:05 +08:00
ouyangxq 8b393d1906 优化 2026-08-13 15:51:27 +08:00
ouyangxq 319900e359 优化 2026-08-13 15:11:01 +08:00
ouyangxq ef9852e757 优化 2026-08-13 14:55:50 +08:00
ouyangxq 939e90912a 优化 2026-08-13 14:38:04 +08:00
ouyangxq 65cd7e0a39 优化 2026-08-13 14:37:51 +08:00
ouyangxq bc62650ca9 优化 2026-08-13 14:03:54 +08:00
ouyangxq 18cebd3fdd 优化 2026-08-13 13:40:15 +08:00
ouyangxq 1df3805334 fix customer statistics mapper scanning 2026-08-13 13:39:36 +08:00
ouyangxq a16b41eb8c fix iam and order deployment test contracts 2026-08-13 13:01:51 +08:00
ouyangxq e5fda0899e fix firstleg deployment contract failures 2026-08-13 12:54:34 +08:00
ouyangxq 756a6b13eb 优化 2026-08-13 12:37:46 +08:00
ouyangxq e742abd1cf 优化 2026-08-13 12:22:31 +08:00
ouyangxq 16dc03a2e2 优化 2026-08-13 11:57:33 +08:00
ouyangxq 5a48992d78 优化 2026-08-13 09:52:59 +08:00
ouyangxq c331133016 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 09:51:29 +08:00
ouyangxq af75569c78 优化 2026-08-13 09:51:27 +08:00
liuyihe 43bbd9c1e8 Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-13 09:51:02 +08:00
liuyihe 74edf76e72 快速下单 2026-08-13 09:50:06 +08:00
ouyangxq 95849f248f 优化 2026-08-13 09:49:55 +08:00
ouyangxq f3366cb2fa 优化 2026-08-12 20:36:50 +08:00
ouyangxq 0ed1531683 优化 2026-08-12 20:36:11 +08:00
ouyangxq 0380c0553b 优化 2026-08-12 20:02:09 +08:00
ouyangxq 30aa0fd945 优化 2026-08-12 19:56:34 +08:00
ouyangxq 8a3f715a3b 优化 2026-08-12 19:51:33 +08:00
ouyangxq 6b1d97af04 优化 2026-08-12 19:47:01 +08:00
ouyangxq 241701d9c5 优化 2026-08-12 19:43:00 +08:00
ouyangxq 47f99befb7 优化 2026-08-12 19:42:08 +08:00
ouyangxq db86e97c0d 优化 2026-08-12 19:30:14 +08:00
ouyangxq e7dfd2790d 优化 2026-08-12 19:14:41 +08:00
ouyangxq af6d348ac3 优化 2026-08-12 19:12:02 +08:00
ouyangxq 9d863500ed 优化 2026-08-12 19:04:45 +08:00
ouyangxq f2f959e8a5 优化 2026-08-12 18:49:46 +08:00
ouyangxq 025467f9ed Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-12 18:23:59 +08:00
ouyangxq ae40d3f173 优化 2026-08-12 18:23:57 +08:00
ouyangxq bac46a0f54 修复PDA稳定柜次查询契约 2026-08-12 18:20:11 +08:00
ouyangxq 4063225352 优化 2026-08-12 18:16:39 +08:00
ouyangxq 5a1cd862c9 优化 2026-08-12 18:00:17 +08:00
ouyangxq 8e8e123d23 优化 2026-08-12 17:45:45 +08:00
ouyangxq b3b8d94957 优化 2026-08-12 17:34:03 +08:00
ouyangxq b5649cf8ae 优化 2026-08-12 17:29:30 +08:00
ouyangxq 4562e3f1d1 优化 2026-08-12 16:58:45 +08:00
ouyangxq 600f3b7861 优化 2026-08-12 16:40:01 +08:00
ouyangxq 1767104406 test finance billed source identity 2026-08-12 16:19:16 +08:00
ouyangxq 0502ffce38 fix finance fee source billed lookup 2026-08-12 16:15:49 +08:00
ouyangxq 6a26b17e0a fix finance acceptance source ownership checks 2026-08-12 15:53:47 +08:00
ouyangxq 645d20665c preserve payable partner in fee snapshots 2026-08-12 15:33:32 +08:00
ouyangxq dca1455478 fix settlement source contracts 2026-08-12 15:21:23 +08:00
ouyangxq 4c7b0e867f fix reconciliation source state transitions 2026-08-12 15:16:24 +08:00
ouyangxq cf3f9c2406 优化 2026-08-12 15:10:40 +08:00
ouyangxq 8c57f5d94e align reconciliation source joins 2026-08-12 15:00:24 +08:00
ouyangxq 633c08045c 优化 2026-08-12 14:56:57 +08:00
ouyangxq 6d50bde31e wait for finance fee pool propagation 2026-08-12 14:56:50 +08:00
ouyangxq 285e17f0ba fix reconciliation source identity 2026-08-12 14:52:53 +08:00
ouyangxq 0d2093054f align finance fixture waybill and pool response 2026-08-12 14:46:52 +08:00
ouyangxq c4e6c28da0 add finance cost allocation detail 2026-08-12 14:40:05 +08:00
ouyangxq fcb7c11af4 优化 2026-08-12 14:37:45 +08:00
ouyangxq d183ad6330 fix container cost snapshot validation 2026-08-12 14:32:48 +08:00
ouyangxq 60895e3bf5 fix finance reconciliation fee pool ids 2026-08-12 14:21:25 +08:00
ouyangxq aaa38f825a 优化 2026-08-12 14:08:29 +08:00
ouyangxq 4375beaa13 补充财务主体缺失阻断诊断 2026-08-12 13:55:16 +08:00
ouyangxq 49967bbc28 test: adapt PDA loading completion contract 2026-08-12 13:48:46 +08:00
ouyangxq 584b73faf5 修复财务整柜成本雪花ID精度 2026-08-12 13:37:47 +08:00
liuyihe c2b1ab92ff 第三期装柜验收优化修改 2026-08-12 13:34:26 +08:00
ouyangxq d2704f68a6 完善财务全量验收链路 2026-08-12 13:24:17 +08:00
ouyangxq 505e834536 优化 2026-08-12 12:50:33 +08:00
ouyangxq d9bbd94366 优化 2026-08-12 12:47:06 +08:00
ouyangxq e4f65dae5f 优化 2026-08-12 12:35:29 +08:00
ouyangxq 0fceece378 优化 2026-08-12 12:18:59 +08:00
ouyangxq 5a26a84f78 优化 2026-08-12 11:25:42 +08:00
ouyangxq 4d0c245b60 优化 2026-08-12 10:59:39 +08:00
ouyangxq 91c6ebca5c fix(firstleg): map PDA query rows to concrete beans 2026-08-12 10:37:50 +08:00
ouyangxq b90470af97 优化 2026-08-12 09:40:50 +08:00
ouyangxq 1cb4fba276 优化 2026-08-12 09:25:53 +08:00
ouyangxq b2af5739c8 优化 2026-08-11 20:39:28 +08:00
ouyangxq 86488da6d0 test(admin): verify PDA inventory snapshots 2026-08-11 20:10:37 +08:00
ouyangxq 5e1e281773 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 20:02:34 +08:00
ouyangxq d7d33c4e9b 优化 2026-08-11 20:02:33 +08:00
ouyangxq 1f4b09f0c9 test(admin): align fee item acceptance payload 2026-08-11 20:02:04 +08:00
liuyihe bed917ba6c 装柜修改 2026-08-11 20:00:31 +08:00
ouyangxq 48bb752007 优化 2026-08-11 19:59:06 +08:00
ouyangxq 7c24c96f88 fix(firstleg): wire finance outbox relay constructor 2026-08-11 19:47:20 +08:00
ouyangxq 3809d2a028 优化 2026-08-11 19:35:18 +08:00
ouyangxq 4d49d7a2e4 fix(deploy): synchronize finance fee source migration 2026-08-11 18:53:04 +08:00
ouyangxq bc5d9a6215 优化 2026-08-11 18:43:50 +08:00
ouyangxq e1487162a5 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 18:17:00 +08:00
ouyangxq 7e52d0c025 优化 2026-08-11 18:16:59 +08:00
ouyangxq 8a65f18fce test(admin): complete phase3 producer fixtures 2026-08-11 18:07:59 +08:00
ouyangxq 47c1c7922f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 18:06:28 +08:00
ouyangxq 192964419f 优化 2026-08-11 18:06:26 +08:00
ouyangxq cad2ba6e91 test(admin): align phase3 loading producer contract 2026-08-11 17:48:36 +08:00
ouyangxq a02dd3a422 test(admin): align phase3 loading producer contract 2026-08-11 17:47:19 +08:00
ouyangxq a59f4634cd fix(firstleg): allow preload confirmation after loading starts 2026-08-11 15:25:49 +08:00
ouyangxq 16a2f83da6 fix(firstleg): start loading when PDA task is claimed 2026-08-11 15:20:41 +08:00
ouyangxq 4561245a71 test(admin): align loading state transition assertions 2026-08-11 15:11:50 +08:00
ouyangxq 386eae6785 test(admin): follow real loading start sequence 2026-08-11 15:11:05 +08:00
liuyihe cb30839945 装柜修改 2026-08-11 14:24:05 +08:00
ouyangxq bf7c214908 fix(firstleg): execute loading completion gate SQL 2026-08-11 14:13:28 +08:00
ouyangxq df0f05c74d test(admin): keep phase3 producer fixtures aligned 2026-08-11 14:07:16 +08:00
ouyangxq 7c2274be31 优化 2026-08-11 13:55:16 +08:00
ouyangxq 40171b7047 fix(firstleg): allow PDA batch operation audit 2026-08-11 11:53:32 +08:00
ouyangxq 3f301e390b test(admin): accept customs source-carried loading slices 2026-08-11 11:49:10 +08:00
ouyangxq 29c010e28f test(admin): retain PDA batch gap until verified 2026-08-11 11:46:23 +08:00
ouyangxq 9b3ff06a90 优化 2026-08-11 11:41:41 +08:00
ouyangxq 9f455b6c0b feat(firstleg): add atomic PDA batch picking endpoint 2026-08-11 11:24:59 +08:00
ouyangxq 7aa2b507f6 fix(firstleg): retry chained remainder settlement 2026-08-11 11:12:29 +08:00
ouyangxq fe0f1665e5 test(admin): align phase3 PDA direct completion fixtures 2026-08-11 11:11:57 +08:00
ouyangxq 166ebfb7ef 优化 2026-08-11 10:46:03 +08:00
ouyangxq c488c8aa02 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 10:45:55 +08:00
ouyangxq e4372783d0 优化 2026-08-11 10:45:53 +08:00
ouyangxq 2de3fb36e9 fix(gateway): include stable request and trace ids in security errors 2026-08-11 10:37:17 +08:00
ouyangxq 4e79287a35 fix(gateway): include stable request and trace ids in security errors 2026-08-11 10:35:01 +08:00
ouyangxq 5f90bf3bc8 fix(order): inherit cargo bindings for remainder orders 2026-08-11 10:11:30 +08:00
ouyangxq 0e47155649 test(admin): follow direct PDA completion contract 2026-08-11 10:10:18 +08:00
ouyangxq 77a91b6553 fix(order): inherit cargo bindings for remainder orders 2026-08-11 10:09:09 +08:00
ouyangxq f5c424b36d test(admin): verify chained remainder fulfillment splits 2026-08-11 10:02:41 +08:00
ouyangxq bceaff496e fix(firstleg): make scanned shipment id optional 2026-08-11 09:57:45 +08:00
ouyangxq dfc1db6e8c test(firstleg): align single-remainder completion contracts 2026-08-11 09:31:43 +08:00
ouyangxq 22cebaaa7a test(order): guard cross-role fulfillment split uniqueness 2026-08-11 09:28:23 +08:00
ouyangxq e6eba04eda Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 09:25:43 +08:00
ouyangxq 540a6f5d88 优化 2026-08-11 09:25:41 +08:00
ouyangxq 66b3a0cc9b feat(order): adopt single-remainder fulfillment splits 2026-08-11 09:22:03 +08:00
ouyangxq fe60a20d95 feat(firstleg): enforce single remainder split schema 2026-08-11 09:18:03 +08:00
ouyangxq 39b9f237e0 test(phase3): verify single-remainder order flow 2026-08-11 09:17:39 +08:00
ouyangxq 5913c3c3ef feat(firstleg): enforce single remainder split schema 2026-08-11 09:17:02 +08:00
liuyihe e915086133 装柜修改 2026-08-10 20:52:46 +08:00
ouyangxq 4630186a56 fix(firstleg): accept unsplit inbound package totals 2026-08-10 20:26:41 +08:00
ouyangxq b1ca37699f fix(firstleg): sequence child inbound codes 2026-08-10 20:18:53 +08:00
ouyangxq 4749bd3bc1 test(warehouse): cover PDA occupancy package counts 2026-08-10 18:44:04 +08:00
ouyangxq 86632ef383 优化 2026-08-10 18:40:43 +08:00
ouyangxq efcd046853 fix(firstleg): make PDA allocation settlement idempotent 2026-08-10 18:17:53 +08:00
ouyangxq a4c47c281c 优化 2026-08-10 18:12:18 +08:00
ouyangxq 3bfc11a94d fix(firstleg): defer PDA allocation release transition 2026-08-10 18:00:24 +08:00
ouyangxq 84209bffd7 fix(phase3): close loading and dispatch flows 2026-08-10 17:42:17 +08:00
ouyangxq 074a95d82b fix(warehouse): allow platform resource scope 2026-08-10 16:34:11 +08:00
ouyangxq 59a4eb8ed5 优化 2026-08-10 16:24:47 +08:00
ouyangxq 9ab7c01639 fix(sql): make fee migration MySQL-compatible 2026-08-10 15:52:40 +08:00
ouyangxq c9f1fe0093 优化 2026-08-10 15:36:47 +08:00
ouyangxq 013de5e921 fix(firstleg): instantiate PDA progress projection 2026-08-10 14:31:03 +08:00
ouyangxq a81f73d835 test(phase3): verify merchant fulfillment scope 2026-08-10 14:18:24 +08:00
ouyangxq ce25a0a72d test(phase3): verify fulfillment aggregates and update schedule gaps 2026-08-10 14:15:04 +08:00
ouyangxq d42392dfc5 test: format port schedule fixtures in Shanghai local time 2026-08-10 14:06:56 +08:00
ouyangxq 55060c7cbe fix(sql): use mysql8 compatible PDA migrations 2026-08-10 13:54:28 +08:00
ouyangxq d2198fff06 fix(phase3): close inbound split trigger condition 2026-08-10 13:51:13 +08:00
ouyangxq d15bc9b583 fix(deploy): restore unified migration final assertions 2026-08-10 13:45:51 +08:00
ouyangxq ac085e0f53 chore(deploy): synchronize phase3 unified database migration 2026-08-10 13:31:42 +08:00
ouyangxq 88390f9401 feat(order): add container cross-order fulfillment aggregation 2026-08-10 13:16:40 +08:00
ouyangxq 3c44f3bd51 Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-10 13:07:15 +08:00
ouyangxq 253c0e7472 feat(phase3): connect PDA settlement to real child orders 2026-08-10 13:02:51 +08:00
ouyangxq 20996b050b fix(iam): 修复仓库角色统一迁移执行 2026-08-10 13:00:13 +08:00
ouyangxq 64ba5ae249 fix(phase3): route carrier queries and repair schedule updates 2026-08-10 12:56:02 +08:00
liuyihe a1bcb519c9 装柜只能限制一个人拣货 2026-08-10 12:50:22 +08:00
ouyangxq ea1346ee2c feat(order): expose fulfillment split contract in openapi 2026-08-10 12:49:15 +08:00
ouyangxq 986e97acb6 优化 2026-08-10 12:46:12 +08:00
ouyangxq fc554273b9 优化 2026-08-10 12:40:47 +08:00
ouyangxq 7684f3cfec test(iam): verify authorization session revocation 2026-08-10 12:37:46 +08:00
ouyangxq 14929bb257 优化 2026-08-10 12:27:16 +08:00
ouyangxq eab51bc5ac Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-10 12:27:09 +08:00
ouyangxq 74ce1358ae 优化 2026-08-10 12:24:01 +08:00
ouyangxq 75d5fd4e26 feat(phase3): 收口父子单与全链路验收 2026-08-10 12:23:43 +08:00
ouyangxq b294695169 fix(iam): 收敛仓库角色权限并纳入统一迁移 2026-08-10 12:22:29 +08:00
ouyangxq edd5108625 feat(firstleg): add compliant carrier schedule queries 2026-08-10 12:20:05 +08:00
ouyangxq 6974aeaaeb fix(deploy): allow expanded authorization headers 2026-08-10 12:07:19 +08:00
ouyangxq 5c1c6a1a07 fix(deploy): allow expanded authorization headers 2026-08-10 12:07:03 +08:00
ouyangxq 1200ef54ad feat(iam): 初始化三期仓库作业角色 2026-08-10 11:54:58 +08:00
liuyihe 037527b4cb 装柜回归测试 2026-08-10 11:30:14 +08:00
ouyangxq 047f267d4f 优化 2026-08-10 11:28:57 +08:00
ouyangxq e33fb923e4 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-10 09:11:57 +08:00
ouyangxq 06db4e3432 优化 2026-08-10 09:11:56 +08:00
liuyihe 0ce264a257 装柜回归测试 2026-08-09 20:35:11 +08:00
ouyangxq b20cd9bd77 chore: stop tracking generated reports 2026-08-09 11:25:59 +08:00
ouyangxq b1cd1b059d test(firstleg): mark phase3 gaps pending deployment 2026-08-09 11:25:54 +08:00
ouyangxq ed5b9fe77f feat(firstleg): converge phase3 loading models and pda 2026-08-09 11:21:38 +08:00
ouyangxq 9b88fb4b9e test(firstleg): cover phase3 outbound contracts 2026-08-08 22:42:49 +08:00
ouyangxq 66d1aa0d3d feat(firstleg): close phase3 outbound customs and schedule gates 2026-08-08 22:36:41 +08:00
ouyangxq 789d97675b fix(firstleg): persist ocean container allocation 2026-08-08 21:49:41 +08:00
ouyangxq da85e30f33 test(firstleg): cover phase3 container loading flow 2026-08-08 21:34:39 +08:00
ouyangxq 2dae8b2029 优化 2026-08-08 21:14:39 +08:00
ouyangxq e5c6e03033 test(admin): stabilize service center acceptance fixtures 2026-08-08 20:47:26 +08:00
ouyangxq aab8e37175 refactor(file): isolate truck attachment storage 2026-08-08 20:45:10 +08:00
ouyangxq 6b0781d120 fix(file): support truck appointment attachments 2026-08-08 20:43:35 +08:00
ouyangxq f37ad6d83d test(admin): accept explicit duplicate import rejection 2026-08-08 20:42:34 +08:00
ouyangxq f239bb739b 优化 2026-08-08 20:39:49 +08:00
ouyangxq 0558f7ae59 优化 2026-08-08 20:36:23 +08:00
ouyangxq 57fcabe740 优化 2026-08-08 15:57:04 +08:00
ouyangxq d49b4128bc fix(warehouse): reject invalid import files with validation code 2026-08-08 15:54:31 +08:00
ouyangxq 931009a518 优化 2026-08-08 15:25:46 +08:00
ouyangxq 7b2b208467 test: expand overseas storage fee acceptance 2026-08-08 15:14:28 +08:00
ouyangxq e395292cd5 优化 2026-08-08 15:05:33 +08:00
ouyangxq 102845874b fix(firstleg): consolidate service center migration contract 2026-08-08 14:33:25 +08:00
ouyangxq 355b8629d9 优化 2026-08-08 14:26:02 +08:00
ouyangxq 13a2b90e98 优化 2026-08-08 14:19:45 +08:00
ouyangxq 87c1bfbb71 修复 2026-08-08 14:17:06 +08:00
ouyangxq 87508bba82 优化 2026-08-08 14:13:28 +08:00
ouyangxq ef5878a85d 优化 2026-08-08 13:57:07 +08:00
ouyangxq b3d6f865b7 fix: scan ocean booking mappers at startup 2026-08-08 13:56:16 +08:00
ouyangxq f690abbaeb 优化 2026-08-08 13:51:28 +08:00
ouyangxq 374ceed493 优化 2026-08-08 13:46:16 +08:00
ouyangxq 3203adb5a7 优化 2026-08-08 13:32:20 +08:00
ouyangxq 96788e10de test: lock weighted Mojia carton totals 2026-08-08 13:18:31 +08:00
ouyangxq b6b5e2dc55 fix(audit): exclude authenticated principal from snapshots 2026-08-08 13:15:01 +08:00
ouyangxq dca8c05d33 test(admin): verify weighted Mojia measurement volume 2026-08-08 13:08:24 +08:00
ouyangxq 44bae99999 fix(firstleg): avoid phase-three reads in phase-two queries 2026-08-08 12:25:00 +08:00
ouyangxq d30e975eed 优化 2026-08-08 12:16:01 +08:00
ouyangxq 08f71cf17e test: cover all customer dimension weighting rules 2026-08-08 12:04:30 +08:00
ouyangxq a35cd868b9 优化 2026-08-08 12:01:45 +08:00
ouyangxq 1a2f598c9a fix(warehouse): keep partner query record bindable 2026-08-08 11:51:52 +08:00
ouyangxq 7c0f2fc5f5 test: keep Mojia samples within customer box limits 2026-08-08 11:50:55 +08:00
ouyangxq 0113d19274 fix: expose phase2 terminal inbound billing 2026-08-08 11:46:20 +08:00
ouyangxq ef4e1d6015 fix: gate device scans by actual measurement submission state 2026-08-08 11:39:28 +08:00
ouyangxq d6ed4a5ad7 test(warehouse): cover unknown partner type list validation 2026-08-08 11:37:21 +08:00
ouyangxq 31d18abbf0 优化 2026-08-08 11:34:03 +08:00
ouyangxq 04c391333b 优化 2026-08-08 11:33:25 +08:00
ouyangxq 74e42e91b6 优化 2026-08-08 11:33:20 +08:00
ouyangxq 92b3b9b1fb 优化 2026-08-08 11:33:18 +08:00
ouyangxq 2ee78aafc2 fix: record oversize device measurements for review 2026-08-08 11:33:02 +08:00
ouyangxq 54893aeb6b 优化 2026-08-08 11:07:23 +08:00
ouyangxq 2644718b5f 优化 2026-08-08 10:31:44 +08:00
ouyangxq ced35f1600 fix: 补齐客户操作权限数据库迁移 2026-08-08 10:30:39 +08:00
ouyangxq 77aa5a845e fix: 允许匿名兑换客户代登录授权码 2026-08-08 10:22:41 +08:00
ouyangxq 5f19cdcccb 优化 2026-08-08 10:18:29 +08:00
ouyangxq 4f48015b81 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 10:17:50 +08:00
ouyangxq 38e7094520 修复订单审核后履约任务未生成 2026-08-08 10:17:46 +08:00
ouyangxq 73bfc5a65c 优化 2026-08-08 10:17:43 +08:00
ouyangxq dd8147c535 fix: carry package count into inbound snapshot 2026-08-08 10:16:33 +08:00
ouyangxq 71fde99ceb Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 10:11:05 +08:00
ouyangxq fc14856ea3 优化 2026-08-08 10:11:04 +08:00
ouyangxq 0c37d40cf1 修复已入库费用发送状态回退 2026-08-08 09:53:39 +08:00
ouyangxq 2779bf9ff7 修复客户尺寸加权与入库计费验收 2026-08-08 09:43:56 +08:00
ouyangxq f3c2bafec8 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 09:17:44 +08:00
ouyangxq 5951e6aa68 优化 2026-08-08 09:17:42 +08:00
liuyihe 0d70edc020 Merge branch 'test-02huogui' into test
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
2026-08-07 23:46:44 +08:00
liuyihe a09639ff05 暂存提交 2026-08-07 23:43:41 +08:00
liuyihe 2b81fd40e7 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
2026-08-07 19:49:09 +08:00
liuyihe 3f15f3ad2b 暂存提交 2026-08-07 19:40:42 +08:00
liuyihe 7c17b1a76a 订单批量审核提交 2026-08-07 15:32:59 +08:00
liuyihe 77dc32a69a 订单批量审核提交 2026-08-07 14:52:21 +08:00
liuyihe 0dc60d835b 装柜 2026-08-07 12:19:47 +08:00
liuyihe efb01f8932 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
#	firstleg/src/main/resources/openapi/firstleg-v1.yaml
2026-08-07 11:11:43 +08:00
liuyihe ebea3a1813 装柜 2026-08-07 10:57:06 +08:00
liuyihe 913eca7b7d 装柜 2026-08-06 17:48:50 +08:00
liuyihe c68eedd3f3 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-06 15:54:25 +08:00
liuyihe c3a978b497 提交 2026-08-06 15:48:21 +08:00
950 changed files with 81878 additions and 4374 deletions
+3
View File
@@ -23,6 +23,9 @@ dependency-reduced-pom.xml
reports/runtime*/ reports/runtime*/
reports/uploads/ reports/uploads/
reports/db-backup*/ reports/db-backup*/
reports/
uploads/ uploads/
.codex-temp* .codex-temp*
.codex-tmp* .codex-tmp*
.codex-*.out
.codex-*.err
+51 -102
View File
@@ -1,118 +1,67 @@
# 柏悦头程后端项目指引 # 国内头程后端项目指引
## 适用范围 ## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
## 当前主体与隔离模型(重要,所有阶段必须遵守) ## 不可突破的产品与数据边界
- 完整目标模型原计划为“租户—平台—客户”三级,但为尽快上线,当前约一年内采用 **业务单租户、平台管理多客户、客户严格隔离** 的模式。当前没有可管理、可选择或可切换的上级租户,也没有真实的“租户—平台—客户”三级关系。 - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- “业务单租户”只是产品阶段约束,不表示代码中存在一个供所有数据共用的默认租户 ID。现有代码和数据库的 `tenant_id` 已经广泛承载 **客户 ID**,是当前客户隔离键;严禁把所有客户数据的 `tenant_id` 统一改成同一个值,否则会直接破坏客户隔离。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 当前需求、一期、二期、三期及后续未明确启用租户能力的开发,统一按“平台—客户”两级实现。不得自行新增租户主表、租户管理、租户切换、租户级配置、客户所属租户选择、租户级权限或其他显式多租户能力。 - 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
当前代码中的主体标识固定按下表理解: ## 服务路由(按关键词定位)
| 现有结构 | 当前业务含义 | 修改约束 | | 任务关键词 | 首选服务 | 先看入口 |
|---|---|---| |---|---|---|
| `iam_tenant.id = 0`、`tenant_type = PLATFORM` | 平台技术作用域 | 必须保留为平台账号、平台角色和平台权限范围,不是未来的默认租户 | | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
| `iam_tenant.id > 0`、`tenant_type = MERCHANT` | 平台直接服务的客户;`iam_tenant` 的这些记录当前直接作为客户主档 | 客户列表和客户业务只能使用 `MERCHANT` 记录,不得把平台记录当客户 | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| Auth/JWT/业务表中的 `tenant_id > 0` | 当前登录客户或数据所属客户的 ID | 商户端从认证上下文取得;平台代客操作显式携带目标客户;Repository/Mapper 必须按该值过滤 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| RBAC 数据中的 `tenant_id = 0` | 平台权限或供客户复用的全局角色/权限模板 | 不代表真实上级租户,不能作为客户数据隔离条件 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| Order/Firstleg 等业务中的 `customer_id` | 订单往来单位、发货客户或其他业务客户快照,具体以模块契约为准 | 不是 IAM 平台客户 ID,不能替代 `tenant_id` 做当前客户隔离 | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| `DEFAULT_TENANT_ID`、`public-tenant-id` 等配置 | 平台公共仓库和主数据的技术归属配置,当前固定为平台作用域 `0` | 不代表未来默认租户;公共资源不得再绑定普通客户 `10001`,变更时必须同步 Warehouse 种子、存量迁移及全部消费者 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
- 当前真正的数据隔离边界是上述客户 ID。服务端必须校验客户归属和访问权限;商户请求不得信任前端传入其他客户 ID,平台跨客户查询或代客操作必须同时受平台应用域、权限码和目标客户条件约束。 ## 全量验收路由
- 平台维护并供所有客户使用的公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id = 0`;客户订单、文件、费用和其他客户私有数据继续使用客户 `tenant_id > 0`。往来单位不是客户私有数据,不能按客户复制或隔离。不得再使用演示客户 `10001` 充当公共资源所有者。
- 代码、数据库和接口中已有的 `tenant`、`Tenant*`、`tenant_id`、`merchant` 等名称属于历史命名和兼容契约,当前不做大规模重命名;但新增代码和文档必须按本节解释其真实含义,禁止看到名称后推断系统已经存在显式租户层。
- 预计约一年后再以独立需求引入真实租户。届时应优先新增独立租户主表或明确的上级 `root_tenant_id`,把所有存量 `MERCHANT` 客户统一归属到一个默认租户,再迁移 Auth、JWT、跨服务 DTO 和各服务数据库;不得原地把现有 `iam_tenant.id` 或业务 `tenant_id` 从客户 ID 改义为上级租户 ID。
- 用户在后续对话明确调整本模型之前,本节是所有新模块和分期任务的事实来源;原型、历史设计或代码中的“租户”文案不得自动扩大实现范围。
## 模块快速索引 - 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
| 模块 | 职责 | 首选入口 | ## 契约、数据库与验证入口
|---|---|---|
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` |
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
| `iam/` | 单租户模式下的客户、客户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照;部分代码仍保留 `Tenant*` 历史命名 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
| `order/` | 订单主数据、客户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox;客户之间按归属隔离,部分字段/接口仍保留 `tenant` 历史命名 | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司、海外仓阶梯费用;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`、`PlatformOverseasStorageFeeController.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
## 高频业务检索入口 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
| 搜索目标 | 先读 | ## 全量 HTTP 验收脚本路由
|---|---|
| 后台用户管理、平台/客户账号、跨客户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 |
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` |
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 |
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| 海外仓阶梯费用 | `warehouse/.../controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;表为 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
## 二期入库快速索引 - 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
| 业务链路 | API 与编排入口 | 数据、跨服务与验证入口 | ## 阶段文档入口
|---|---|---|
| 平台入库单列表、页签与聚合详情 | `firstleg/.../controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
| 平台测量草稿、提交及重量 OCR 结果 | `firstleg/.../controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | 图片走 `client/MeasurementImageClient.java`,托盘库位走 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
| 第三方尺寸测量回传 | `firstleg/.../controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 订单货物校验走 `client/OrderCargoClient.java` → `order/.../controller/InternalOrderCargoController.java`;证据图片走 File 内部接口;配置 `config/MeasurementOpenApiProperties.java`,测试 `service/InboundMeasurementOpenApiServiceTest.java` |
| 测量审核、异常放行与自动完成入库 | `firstleg/.../controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` → `AutomaticInboundCompletionService.java` | 费用与入库状态编排在 `FbaPricingService.java`、`InboundCompletionOrchestrator.java`;测试 `service/MeasurementReviewServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
| FBA 客户规则、承运商价格与费用快照 | 主数据 `firstleg/.../controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java`;计费 `PlatformFbaPricingController.java` → `service/FbaPricingService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`、`src/main/resources/db/phase2-fba-pricing-migration.sql`;测试 `service/FbaMasterDataServiceTest.java`、`FbaPricingServiceTest.java` |
| 客户查看入库费用 | `firstleg/.../controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 只按已认证客户查询;`Merchant*` 及 `/api/merchant/` 为历史兼容命名,接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` |
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
| 数据库部署 | 空库/存量环境唯一入口 `deploy/sql/头程数据库统一增量-MySQL8.sql` | 覆盖全部一期、二期及后续增量;可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,历史日期脚本不得再单独执行 |
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。 - 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。 - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
## 配置、数据库与验证入口 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
## 通用修改约束
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
- 当前采用业务单租户、平台管理多客户模式。现有 `tenant_id` 是客户归属与隔离键,客户数据必须保留该维度,并由服务端执行平台/客户访问域、客户数据隔离、数据范围和按钮权限校验;不得把它统一替换为所谓默认租户 ID,也不得据此允许跨客户访问。
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
## 本地内部服务令牌
- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。
- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。
Vendored
+20 -4
View File
@@ -1,5 +1,5 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'event-bridge-service', 'audit-service'
] ]
@@ -8,6 +8,7 @@ def serviceCheckboxes = [
'auth': 'SERVICE_AUTH', 'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM', 'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG', 'firstleg': 'SERVICE_FIRSTLEG',
'finance': 'SERVICE_FINANCE',
'order': 'SERVICE_ORDER', 'order': 'SERVICE_ORDER',
'warehouse': 'SERVICE_WAREHOUSE', 'warehouse': 'SERVICE_WAREHOUSE',
'file': 'SERVICE_FILE', 'file': 'SERVICE_FILE',
@@ -23,6 +24,8 @@ def commonPaths = [
'Jenkinsfile.service', 'deploy/' 'Jenkinsfile.service', 'deploy/'
] ]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:] def downstreamResults = [:]
pipeline { pipeline {
@@ -41,6 +44,7 @@ pipeline {
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)') booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
@@ -81,6 +85,7 @@ pipeline {
error('DOWNSTREAM_JOB_PREFIX 不能为空') error('DOWNSTREAM_JOB_PREFIX 不能为空')
} }
env.RESOLVED_JOB_PREFIX = jobPrefix env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
List selected = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
@@ -99,8 +104,13 @@ pipeline {
} else { } else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it } .trim().split('\\n').collect { it.trim() }.findAll { it }
boolean databaseChanged = changed.any { path ->
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
boolean commonChanged = changed.any { path -> boolean commonChanged = changed.any { path ->
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
} }
selected = commonChanged ? serviceModules : serviceModules.findAll { module -> selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") } changed.any { it == module || it.startsWith("${module}/") }
@@ -117,13 +127,19 @@ pipeline {
env.SELECTED_SERVICES = selected.join(',') env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) { if (!selected) {
currentBuild.description = '没有需要构建的业务服务' currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
} else { } else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}" String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
} }
if (env.DATABASE_CHANGE_DETECTED == 'true') {
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
}
} }
} }
} }
+10 -2
View File
@@ -1,5 +1,5 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'event-bridge-service', 'audit-service'
] ]
@@ -8,6 +8,7 @@ def serviceJars = [
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
'finance': 'finance/target/finance-service-1.0.0-SNAPSHOT.jar',
'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar', 'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar',
'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar', 'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar',
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
@@ -109,7 +110,14 @@ pipeline {
stage('Maven 测试与打包') { stage('Maven 测试与打包') {
steps { steps {
script { script {
String testOption = params.SKIP_TESTS ? '-DskipTests' : '' if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
} }
} }
+3 -1
View File
@@ -11,6 +11,7 @@
|---|---| |---|---|
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` | | 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) | | 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` |
| 接收 DTO | `dto/AuditIngestRequest.java` | | 接收 DTO | `dto/AuditIngestRequest.java` |
| 入库编排 | `service/AuditIngestService.java` | | 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
@@ -23,5 +24,6 @@
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。 - `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。 - `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。 - 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。 - 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。 - 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
+11 -11
View File
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明', description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID', trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID', tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0',
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID', operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0',
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID', user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照', username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端', app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
@@ -38,17 +38,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; ) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷', payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0, retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL, lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL, lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL, last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL, processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id), UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until) KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
@@ -22,17 +22,17 @@ CALL add_audit_event_id();
DROP PROCEDURE IF EXISTS add_audit_event_id; DROP PROCEDURE IF EXISTS add_audit_event_id;
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID', event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷', payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0, retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL, lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL, lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL, last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL, processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id), UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until) KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
+23 -24
View File
@@ -1,32 +1,31 @@
# Auth Service 模块指引 # Auth Service 模块指引
## 职责边界 ## 职责与边界
- 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。现有账号/JWT 中的 `tenant_id > 0` 当前是客户 ID,`tenant_id=0` 是平台技术作用域;字段名保持兼容,不代表已经存在真实租户层。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 快速索引 ## 模块入口
| 功能 | 入口 | | 目标 | 入口 |
|---|---| |---|---|
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 | | 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 | | 注册协作 | `service/MerchantRegistrationService.java` |
| 商户注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` | | DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` | | SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、兼容字段 `tenantId`、成员与应用端 | | 测试/验证 | `src/test/java/com/baiyue/auth/`;`mvn -pl auth -am test` |
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
## 修改与验证 ## 修改约束
- 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。 - 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
- 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。 ## 全量验收导航
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
+76
View File
@@ -0,0 +1,76 @@
# 国内与海外 JWT/JWKS 冻结契约
## 双轨边界
### 国内现有链路
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
### 海外尾程链路
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
## 海外签发契约
- `issuer`:`auth-service`
- `audience`:`lastleg-api`
- 算法白名单:仅 `RS256`
- Access Token 默认有效期:15 分钟
- 兑换接口:`POST /api/auth/overseas/token`
- JWKS 路径:`/api/auth/.well-known/jwks.json`
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
海外 Token 的 JSON 声明名称和类型固定如下:
| 声明 | JSON 类型 | 说明 |
|---|---|---|
| `sub` | string | 用户 ID 的字符串形式 |
| `jti` | string | Token 唯一标识 |
| `iat` | number | 签发时间 |
| `exp` | number | 过期时间 |
| `aud` | string array | 固定包含 `lastleg-api` |
| `userId` | number | 认证用户 ID |
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
| `roles` | string array | 当前角色编码快照 |
| `authorities` | string array | 当前权限编码快照 |
| `permissionVersion` | number | IAM 单调权限版本 |
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
## 海外配置
```yaml
baiyue:
security:
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
```
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
## 密钥轮换
- JWK Set 只允许 RSA 密钥。
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
## IAM 安全事件
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
@@ -13,7 +13,7 @@ import org.springframework.stereotype.Component;
import java.util.Objects; import java.util.Objects;
/** /**
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。
*/ */
@Component @Component
public class AuthorizationLoader { public class AuthorizationLoader {
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
private String clientType; private String clientType;
private Set<String> roles; private Set<String> roles;
private Set<String> permissions; private Set<String> permissions;
private Long permissionVersion;
} }
@@ -1,6 +1,5 @@
package com.baiyue.auth.config; package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.baiyue.common.security.JwtAuthenticationFilter; import com.baiyue.common.security.JwtAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
/** /**
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。 * 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
@@ -25,15 +21,21 @@ import java.nio.charset.StandardCharsets;
@Configuration @Configuration
@EnableConfigurationProperties(AuthSecurityProperties.class) @EnableConfigurationProperties(AuthSecurityProperties.class)
public class AuthSecurityConfig { public class AuthSecurityConfig {
@Bean static final String[] PUBLIC_AUTH_ENDPOINTS = {
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } "/api/auth/register",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/api/auth/impersonations/exchange",
"/api/auth/.well-known/jwks.json",
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
};
@Bean @Bean
JwtEncoder jwtEncoder(AuthSecurityProperties properties) { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
@Bean @Bean
SecurityFilterChain authSecurityFilterChain(HttpSecurity http, SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
@@ -47,8 +49,7 @@ public class AuthSecurityConfig {
.exceptionHandling(errors -> errors .exceptionHandling(errors -> errors
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout", .requestMatchers(PUBLIC_AUTH_ENDPOINTS).permitAll()
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/internal/auth/**").permitAll() .requestMatchers("/internal/auth/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/ */
@ConfigurationProperties(prefix = "baiyue.security") @ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer, public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl, Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) { int maxLoginFailures, Duration lockDuration) {
} }
@@ -0,0 +1,22 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import java.nio.charset.StandardCharsets;
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
@Configuration(proxyBeanMethods = false)
public class DomesticJwtConfiguration {
@Bean("jwtEncoder")
@Primary
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
}
@@ -0,0 +1,37 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OverseasJwtProperties.class)
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasJwtConfiguration {
@Bean("overseasJwtEncoder")
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
try {
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
if (current == null || !current.isPrivate()) {
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
throw new IllegalStateException("海外非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,10 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.dto.MerchantRegisterResponse; import com.baiyue.auth.dto.MerchantRegisterResponse;
import com.baiyue.auth.service.MerchantRegistrationService; import com.baiyue.auth.service.MerchantRegistrationService;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.security.SecurityRequirements;
@@ -18,7 +20,11 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
/** /**
@@ -34,16 +40,40 @@ public class AuthController {
private MerchantRegistrationService registrationService; private MerchantRegistrationService registrationService;
@Resource @Resource
private AccountManagementService accountManagementService; private AccountManagementService accountManagementService;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
@AuthenticationPrincipal AuthenticatedUser user) {
OverseasTokenService service = overseasTokens.getIfAvailable();
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
return ApiResponse.success(service.exchange(user.userId()));
}
@PutMapping("/password") @PutMapping("/password")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前用户密码") @Operation(summary = "修改当前用户密码")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ChangePasswordRequest request) { @Valid @RequestBody ChangePasswordRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
accountManagementService.changePassword(user.userId(), request); accountManagementService.changePassword(user.userId(), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@PutMapping("/password/forced")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "完成强制改密")
public ApiResponse<Void> forcedChangePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ForcedPasswordChangeRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
if (!user.passwordChangeRequired()) throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
accountManagementService.forcedChangePassword(user.userId(), request);
return ApiResponse.success(null);
}
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @Operation(summary = "Merchant registration / 客户注册",
@@ -82,4 +112,11 @@ public class AuthController {
authService.logout(request.refreshToken()); authService.logout(request.refreshToken());
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@SecurityRequirements
@PostMapping("/impersonations/exchange")
public ApiResponse<AuthService.ImpersonationTokenResponse> exchangeImpersonation(
@Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) {
return ApiResponse.success(authService.exchangeImpersonationTicket(request.code()));
}
} }
@@ -2,6 +2,7 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -20,6 +21,8 @@ import java.security.MessageDigest;
public class InternalAccountController { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource
private AuthService authService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -55,6 +58,12 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
verify(token); service.updateStatus(id, request); return ApiResponse.success(null); verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
} }
@PostMapping("/sessions/revoke")
public ApiResponse<Void> revokeSessions(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
}
@PutMapping("/{id}/profile") @PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
@@ -63,7 +72,20 @@ public class InternalAccountController {
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
verify(token); service.resetPassword(id, request); return ApiResponse.success(null); verify(token); service.resetPassword(id, request, request.forceChange()); return ApiResponse.success(null);
}
@PostMapping("/impersonation-tickets")
public ApiResponse<ImpersonationTicketResponse> impersonationTicket(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody ImpersonationTicketRequest request) {
verify(token); return ApiResponse.success(authService.createImpersonationTicket(request));
}
@PutMapping("/tenant/{tenantId}/archive")
public ApiResponse<Void> archiveTenant(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long tenantId) {
verify(token); service.archiveTenant(tenantId); return ApiResponse.success(null);
} }
@PutMapping("/{id}/scope") @PutMapping("/{id}/scope")
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@@ -0,0 +1,31 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.jwk.JWKSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
}
}
@@ -2,10 +2,12 @@ package com.baiyue.auth.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
/** 账号安全及内部账号管理契约。 */ /** 账号安全及内部账号管理契约。 */
public final class AccountManagementDtos { public final class AccountManagementDtos {
@@ -14,6 +16,10 @@ public final class AccountManagementDtos {
public record ChangePasswordRequest(@NotBlank String oldPassword, public record ChangePasswordRequest(@NotBlank String oldPassword,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record ForcedPasswordChangeRequest(
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId, public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@@ -31,8 +37,23 @@ public final class AccountManagementDtos {
} }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest(
@NotEmpty @Size(max = 500) Set<@NotNull Long> accountIds) {
public RevokeAccountSessionsRequest {
accountIds = Set.copyOf(accountIds);
}
}
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword,
Boolean forceChangePassword) {
public boolean forceChange() { return Boolean.TRUE.equals(forceChangePassword); }
}
public record ResetPasswordOptions(boolean forceChangePassword) { }
public record ImpersonationTicketRequest(@NotNull Long targetAuthUserId, @NotNull Long tenantId,
@NotNull Long membershipId, @NotNull Long actorUserId,
@NotNull Long actorMembershipId, @NotBlank String reason) { }
public record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status, public record AccountSummary(Long accountId, String username, String displayName, String status,
@@ -24,6 +24,7 @@ public class AuthAccount {
private Integer failedLoginCount; private Integer failedLoginCount;
private LocalDateTime lockedUntil; private LocalDateTime lockedUntil;
private Integer tokenVersion; private Integer tokenVersion;
private Boolean passwordChangeRequired;
private Long defaultTenantId; private Long defaultTenantId;
private Long operatorTenantId; private Long operatorTenantId;
private Long defaultMembershipId; private Long defaultMembershipId;
@@ -6,6 +6,7 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.mapper.AuthAccountMapper; import com.baiyue.auth.mapper.AuthAccountMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
/** /**
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。 * 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
@@ -32,6 +33,12 @@ public class AuthAccountRepository {
return mapper.selectById(id); return mapper.selectById(id);
} }
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getDefaultTenantId, tenantId)
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
}
public void save(AuthAccount account) { public void save(AuthAccount account) {
mapper.updateById(account); mapper.updateById(account);
} }
@@ -13,6 +13,7 @@ import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */ /** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
@Service @Service
@@ -29,13 +30,30 @@ public class AccountManagementService {
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
public void changePassword(Long accountId, ChangePasswordRequest request) { public void changePassword(Long accountId, ChangePasswordRequest request) {
AuthAccount account = required(accountId); AuthAccount account = required(accountId);
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
}
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) { if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误"); throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
} }
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同"); throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
} }
setPasswordAndRevoke(account, request.newPassword()); setPasswordAndRevoke(account, request.newPassword(), false);
}
@Transactional
@BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "完成管理员重置后的强制改密", captureAfter = false)
public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) {
AuthAccount account = required(accountId);
if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
}
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同");
}
setPasswordAndRevoke(account, request.newPassword(), false);
} }
@Transactional @Transactional
@@ -81,6 +99,14 @@ public class AccountManagementService {
bumpVersion(account); bumpVersion(account);
} }
@Transactional
@BusinessAudit(event = "ACCOUNT_SESSIONS_REVOKE", resourceType = "ACCOUNT_BATCH",
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) {
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
}
@Transactional @Transactional
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", @BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步账号显示名称", captureAfter = false) description = "同步账号显示名称", captureAfter = false)
@@ -93,8 +119,21 @@ public class AccountManagementService {
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request) { public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
setPasswordAndRevoke(required(accountId), request.newPassword()); setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId",
description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false)
public void archiveTenant(Long tenantId) {
for (AuthAccount account : repository.findMerchantByTenant(tenantId)) {
account.setAccountStatus("DISABLED");
account.setPasswordChangeRequired(false);
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
repository.save(account);
tokenSessionStore.revokeAll(account.getId());
}
} }
@Transactional @Transactional
@@ -125,10 +164,11 @@ public class AccountManagementService {
public AccountSummary summary(Long accountId) { return summary(required(accountId)); } public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
private void setPasswordAndRevoke(AuthAccount account, String password) { private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) {
account.setPasswordHash(passwordEncoder.encode(password)); account.setPasswordHash(passwordEncoder.encode(password));
account.setFailedLoginCount(0); account.setFailedLoginCount(0);
account.setLockedUntil(null); account.setLockedUntil(null);
account.setPasswordChangeRequired(forceChangePassword);
bumpVersion(account); bumpVersion(account);
} }
@@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.auth.dto.LoginRequest; import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.dto.TokenResponse; import com.baiyue.auth.dto.TokenResponse;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -27,9 +29,11 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.UUID; import java.util.UUID;
import java.time.Duration;
import java.util.List;
/** /**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
*/ */
@Service @Service
public class AuthService { public class AuthService {
@@ -105,7 +109,8 @@ public class AuthService {
String jwtId = UUID.randomUUID().toString(); String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString()) .subject(account.getId().toString())
.claim("user_id", account.getId()) .claim("user_id", account.getId())
.claim("username", account.getUsername()) .claim("username", account.getUsername())
@@ -114,22 +119,85 @@ public class AuthService {
.claim("membership_id", account.getDefaultMembershipId()) .claim("membership_id", account.getDefaultMembershipId())
.claim("app_code", account.getDefaultAppCode()) .claim("app_code", account.getDefaultAppCode())
.claim("client_type", authorization.getClientType()) .claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions())
.claim("token_version", account.getTokenVersion()) .claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion())
.build(); .build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); signingHeader(), claims)).getTokenValue();
byte[] random = new byte[48]; byte[] random = new byte[48];
secureRandom.nextBytes(random); secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), rawRefresh, tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl()); properties.accessTokenTtl(), properties.refreshTokenTtl());
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds()); rawRefresh, properties.refreshTokenTtl().toSeconds());
} }
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "IMPERSONATION",
resourceId = "#request.tenantId", description = "创建客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTicketResponse createImpersonationTicket(ImpersonationTicketRequest request) {
AuthAccount account = accountRepository.findById(request.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|| !"MERCHANT".equals(account.getDefaultAppCode())
|| !request.tenantId().equals(account.getDefaultTenantId())
|| !request.membershipId().equals(account.getDefaultMembershipId())) {
throw new BizException(ResultCode.CONFLICT, "目标客户管理员账号不可代登录");
}
byte[] bytes = new byte[48];
secureRandom.nextBytes(bytes);
String code = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
String ticketId = UUID.randomUUID().toString();
tokenSessionStore.storeImpersonationTicket(code,
new TokenSessionStore.ImpersonationTicket(ticketId, account.getId(), request.tenantId(),
request.membershipId(), request.actorUserId(), request.actorMembershipId(), request.reason()),
Duration.ofSeconds(60));
return new ImpersonationTicketResponse(code, ticketId, 60);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_EXCHANGE", resourceType = "IMPERSONATION",
resourceId = "'IMPERSONATION_EXCHANGE'", description = "兑换客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTokenResponse exchangeImpersonationTicket(String code) {
TokenSessionStore.ImpersonationTicket ticket = tokenSessionStore.consumeImpersonationTicket(code);
if (ticket == null) throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
AuthAccount account = accountRepository.findById(ticket.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "目标客户账号不可用");
AuthorizationSnapshot authorization = authorizationLoader.load(account);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT")
.claim("permissionVersion", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
}
private static BizException invalidCredentials() { private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
} }
@@ -0,0 +1,56 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties;
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
public OverseasTokenResponse exchange(long authenticatedUserId) {
AuthAccount account = accounts.findById(authenticatedUserId);
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
}
AuthorizationSnapshot authorization = authorizationLoader.load(account);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
@@ -41,13 +41,15 @@ public class TokenSessionStore {
keyPrefix = RedisTokenKeys.normalize(keyPrefix); keyPrefix = RedisTokenKeys.normalize(keyPrefix);
} }
public void store(String jwtId, Long userId, String rawRefreshToken, public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
Duration accessTtl, Duration refreshTtl) { String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId); String userKey = userSessionsKey(userId);
try { try {
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl); String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet(); SetOperations<String, String> sets = redisTemplate.opsForSet();
@@ -59,6 +61,41 @@ public class TokenSessionStore {
} }
} }
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) {
try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) {
try {
redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode),
objectMapper.writeValueAsString(ticket), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public ImpersonationTicket consumeImpersonationTicket(String rawCode) {
try {
String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode));
if (value == null || value.isBlank()) return null;
return objectMapper.readValue(value, ImpersonationTicket.class);
} catch (DataAccessException exception) {
throw unavailable(exception);
} catch (Exception exception) {
throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
}
}
public RefreshSession consume(String rawRefreshToken) { public RefreshSession consume(String rawRefreshToken) {
try { try {
String value = redisTemplate.opsForValue().getAndDelete( String value = redisTemplate.opsForValue().getAndDelete(
@@ -102,7 +139,7 @@ public class TokenSessionStore {
while (cursor.hasNext()) { while (cursor.hasNext()) {
String key = cursor.next(); String key = cursor.next();
String value = redisTemplate.opsForValue().get(key); String value = redisTemplate.opsForValue().get(key);
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) { if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
result.add(key); result.add(key);
} }
} }
@@ -117,6 +154,16 @@ public class TokenSessionStore {
catch (Exception ignored) { return false; } catch (Exception ignored) { return false; }
} }
private boolean accessBelongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
catch (Exception ignored) { return false; }
}
private static Set<String> normalized(Set<String> values) {
return values == null ? Set.of() : Set.copyOf(values);
}
private String userSessionsKey(Long userId) { private String userSessionsKey(Long userId) {
return keyPrefix + ":user-sessions:" + userId; return keyPrefix + ":user-sessions:" + userId;
} }
@@ -136,4 +183,13 @@ public class TokenSessionStore {
} }
public record RefreshSession(Long userId, String accessJwtId) { } public record RefreshSession(Long userId, String accessJwtId) { }
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession {
roles = normalized(roles);
permissions = normalized(permissions);
}
}
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
Long membershipId, Long actorUserId, Long actorMembershipId,
String reason) { }
} }
+9 -1
View File
@@ -34,10 +34,18 @@ baiyue.security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
access-token-ttl: ${ACCESS_TOKEN_TTL:30m} audience: ${JWT_AUDIENCE:lastleg-api}
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5} max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m} lock-duration: ${LOGIN_LOCK_DURATION:15m}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
baiyue.clients: baiyue.clients:
iam: iam:
+9 -9
View File
@@ -8,17 +8,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_outbox (
resource_id VARCHAR(128) NULL COMMENT '资源ID', resource_id VARCHAR(128) NULL COMMENT '资源ID',
service_name VARCHAR(64) NULL COMMENT '来源服务', service_name VARCHAR(64) NULL COMMENT '来源服务',
payload_json JSON NOT NULL COMMENT '脱敏后的审计事件', payload_json JSON NOT NULL COMMENT '脱敏后的审计事件',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数', attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数', compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间',
lease_owner VARCHAR(128) NULL, lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
lease_until DATETIME(3) NULL, lease_until DATETIME(3) NULL COMMENT '发送租约截止时间',
last_error VARCHAR(1000) NULL, last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息',
dead_at DATETIME(3) NULL, dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL, sent_at DATETIME(3) NULL COMMENT '成功发送时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
PRIMARY KEY (event_id), PRIMARY KEY (event_id),
KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at) KEY idx_audit_outbox_retention (status, sent_at)
+10 -9
View File
@@ -8,14 +8,15 @@ CREATE TABLE IF NOT EXISTS auth_account (
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED', account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM', default_membership_id BIGINT DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间', last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
user_id BIGINT NOT NULL COMMENT '认证账号ID', user_id BIGINT NOT NULL COMMENT '认证账号ID',
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID', tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID', operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID',
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端', app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
expires_at DATETIME(3) NOT NULL COMMENT '到期时间', expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
@@ -39,6 +40,6 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
UNIQUE KEY uk_auth_refresh_hash (token_hash), UNIQUE KEY uk_auth_refresh_hash (token_hash),
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at), KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
KEY idx_auth_refresh_expire (expires_at, revoked_at) KEY idx_auth_refresh_expire (expires_at, revoked_at)
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态'; ) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态';
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。 -- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
@@ -6,19 +6,20 @@ SET NAMES utf8mb4;
INSERT INTO auth_account ( INSERT INTO auth_account (
id, username, password_hash, display_name, account_status, id, username, password_hash, display_name, account_status,
failed_login_count, token_version, default_tenant_id, failed_login_count, token_version, password_change_required, default_tenant_id,
operator_tenant_id, default_membership_id, default_app_code, deleted operator_tenant_id, default_membership_id, default_app_code, deleted
) VALUES ) VALUES
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0), '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0) 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash), password_hash = VALUES(password_hash),
display_name = VALUES(display_name), display_name = VALUES(display_name),
account_status = 'ACTIVE', account_status = 'ACTIVE',
failed_login_count = 0, failed_login_count = 0,
locked_until = NULL, locked_until = NULL,
password_change_required = VALUES(password_change_required),
default_tenant_id = VALUES(default_tenant_id), default_tenant_id = VALUES(default_tenant_id),
operator_tenant_id = VALUES(operator_tenant_id), operator_tenant_id = VALUES(operator_tenant_id),
default_membership_id = VALUES(default_membership_id), default_membership_id = VALUES(default_membership_id),
@@ -0,0 +1,11 @@
USE baiyue_auth;
SET @add_password_change_required = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
AND column_name = 'password_change_required'),
'SELECT 1',
'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version');
PREPARE phase1_customer_account_sql FROM @add_password_change_required;
EXECUTE phase1_customer_account_sql;
DEALLOCATE PREPARE phase1_customer_account_sql;
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) { private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode, return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view")); Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
} }
} }
@@ -0,0 +1,15 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AuthSecurityConfigTest {
@Test
void permitsAnonymousImpersonationCodeExchange() {
assertTrue(Arrays.asList(AuthSecurityConfig.PUBLIC_AUTH_ENDPOINTS)
.contains("/api/auth/impersonations/exchange"));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class AuthTokenTtlConfigurationContractTest {
@Test
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
String configuration = Files.readString(
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
assertThat(configuration)
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class DualTrackJwtConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
java.time.Duration.ofMinutes(15)))
.withPropertyValues(
"baiyue.security.overseas-jwt.enabled=false");
@Test
void startsDomesticEncoderWithoutRsaConfiguration() {
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasBean("jwtEncoder");
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
assertThat(context).hasSingleBean(JwtEncoder.class);
});
}
}
@@ -0,0 +1,40 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class JwksControllerTest {
@Test
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
RSAKey current = key("current", true);
RSAKey previous = key("previous", false);
String json = new JWKSet(List.of(current, previous)).toString(false);
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
"current", Duration.ofMinutes(15));
var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
}
private RSAKey key(String kid, boolean privatePart) {
RSAKey.Builder builder = new RSAKey.Builder(
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
return builder.build();
}
}
@@ -0,0 +1,34 @@
package com.baiyue.auth.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InitialAccountSeedSqlContractTest {
@Test
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
.getContentAsString(StandardCharsets.UTF_8));
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
}
private static void assertAccountSeed(String sql) {
assertThat(sql)
.contains("(1000001, 'platform_admin'")
.contains("'平台系统管理员', 'ACTIVE'")
.contains("(1000002, 'merchant_admin'")
.contains("'IT 测试客户管理员', 'ACTIVE'")
.contains("password_change_required")
.doesNotContain("一期演示企业管理员");
}
private static Path unifiedSql() {
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -1,6 +1,7 @@
package com.baiyue.auth.service; package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; 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.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
@@ -23,6 +24,8 @@ import jakarta.annotation.Resource;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
import java.util.List;
@DisplayName("账号管理服务") @DisplayName("账号管理服务")
@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class}) @SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class})
@@ -54,6 +57,25 @@ class AccountManagementServiceTest {
verify(sessions, never()).revokeAll(any()); verify(sessions, never()).revokeAll(any());
} }
@Test void forcedPasswordChangeRequiresResetFlagAndClearsIt() {
account.setPasswordChangeRequired(true);
service.forcedChangePassword(10L, new ForcedPasswordChangeRequest("NewPass2"));
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
assertFalse(account.getPasswordChangeRequired());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void normalPasswordChangeIsRejectedUntilForcedChangeIsCompleted() {
account.setPasswordChangeRequired(true);
assertThrows(BizException.class,
() -> service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")));
verify(repository, never()).save(any());
}
@Test void disableAccountRevokesAllSessions() { @Test void disableAccountRevokesAllSessions() {
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
assertEquals("DISABLED", account.getAccountStatus()); assertEquals("DISABLED", account.getAccountStatus());
@@ -134,6 +156,57 @@ class AccountManagementServiceTest {
verify(repository).save(account); verify(repository).save(account);
verify(sessions).revokeAll(10L); verify(sessions).revokeAll(10L);
} }
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
when(repository.findById(11L)).thenReturn(second);
service.revokeSessions(Set.of(10L, 11L));
assertEquals(3, account.getTokenVersion());
assertEquals(8, second.getTokenVersion());
verify(repository).save(account);
verify(repository).save(second);
verify(sessions).revokeAll(10L);
verify(sessions).revokeAll(11L);
}
@Test void archiveTenantDisablesEveryMerchantAccountAndRevokesSessions() {
AuthAccount owner = merchantAccount(20L, 10001L, "ACTIVE", 3);
AuthAccount member = merchantAccount(21L, 10001L, "DISABLED", null);
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of(owner, member));
service.archiveTenant(10001L);
assertEquals("DISABLED", owner.getAccountStatus());
assertEquals(4, owner.getTokenVersion());
assertEquals("DISABLED", member.getAccountStatus());
assertEquals(1, member.getTokenVersion());
verify(repository).save(owner);
verify(repository).save(member);
verify(sessions).revokeAll(20L);
verify(sessions).revokeAll(21L);
}
@Test void archiveTenantIsSuccessfulWhenCustomerHasNoAccounts() {
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of());
service.archiveTenant(10001L);
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
private AuthAccount merchantAccount(long id, long tenantId, String status, Integer tokenVersion) {
AuthAccount value = new AuthAccount();
value.setId(id);
value.setDefaultTenantId(tenantId);
value.setDefaultAppCode("MERCHANT");
value.setAccountStatus(status);
value.setTokenVersion(tokenVersion);
return value;
}
@Configuration @Configuration
static class TestConfig { static class TestConfig {
@Bean @Bean
@@ -0,0 +1,130 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.AuthSecurityProperties;
import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {AuthService.class, AuthServiceSmallJwtTest.TestConfig.class})
class AuthServiceSmallJwtTest {
@MockBean private AuthAccountRepository repository;
@MockBean private PasswordEncoder passwordEncoder;
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10));
}
}
@Test
void signsCrossRegionClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
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("PLATFORM_ADMIN"), Set.of("platform:order:list"));
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).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("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("user_id", 42L)
.containsEntry("userId", 42L)
.containsEntry("tenantId", 0L)
.containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
}
@Test
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
AuthAccount account = new AuthAccount();
account.setId(43L);
account.setUsername("merchant-owner");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(85L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
when(repository.findById(43L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
service.exchangeImpersonationTicket("one-time-code");
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("appCode", "MERCHANT")
.containsEntry("impersonated", true)
.containsEntry("membership_id", 85L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
}
}
@@ -0,0 +1,74 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
class OverseasTokenServiceTest {
@MockBean private AuthAccountRepository accounts;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
@Resource private OverseasTokenService service;
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean OverseasJwtProperties properties() {
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
Duration.ofMinutes(15));
}
}
@Test
void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount();
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-token");
var response = service.exchange(42L);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
var parameters = captor.getValue();
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
assertThat(response.expiresIn()).isEqualTo(900);
}
}
@@ -46,9 +46,14 @@ class TokenSessionStoreTest {
ValueOperations<String, String> values = mock(ValueOperations.class); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30)); verify(values).set(eq("baiyue:auth:access:jti-1"),
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
&& value.contains("\"userId\":42")
&& value.contains("ADMIN") && value.contains("order:read")),
eq(Duration.ofMinutes(30)));
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"), verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
} }
@@ -41,7 +41,7 @@ public class BusinessAuditAspect {
@Around("@annotation(audit)") @Around("@annotation(audit)")
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable { public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
long start = System.nanoTime(); long start = System.nanoTime();
String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null; String before = audit.captureBefore() ? sanitizer.sanitize(businessArguments(pjp.getArgs())) : null;
Object result = null; Throwable failure = null; Object result = null; Throwable failure = null;
try { result = pjp.proceed(); return result; } try { result = pjp.proceed(); return result; }
catch (Throwable ex) { failure = ex; throw ex; } catch (Throwable ex) { failure = ex; throw ex; }
@@ -79,6 +79,12 @@ public class BusinessAuditAspect {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null; return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
} }
private Object[] businessArguments(Object[] arguments) {
if (arguments == null || arguments.length == 0) return new Object[0];
return java.util.Arrays.stream(arguments)
.filter(argument -> !(argument instanceof AuthenticatedUser))
.toArray();
}
private String evaluateResourceId(ProceedingJoinPoint p, String expression) { private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
if (expression == null || expression.isBlank()) return null; if (expression == null || expression.isBlank()) return null;
Object value = evaluate(p, expression); Object value = evaluate(p, expression);
@@ -29,12 +29,14 @@ class BusinessAuditAspectTest {
@Test @Test
void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable { void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable {
AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L, AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L); "platform.admin", "PLATFORM", "WEB", Set.of("platform:customer:edit")), 10001L);
assertThat(event.tenantId()).isEqualTo(10001L); assertThat(event.tenantId()).isEqualTo(10001L);
assertThat(event.operatorTenantId()).isZero(); assertThat(event.operatorTenantId()).isZero();
assertThat(event.userId()).isEqualTo(7L); assertThat(event.userId()).isEqualTo(7L);
assertThat(event.appCode()).isEqualTo("PLATFORM"); assertThat(event.appCode()).isEqualTo("PLATFORM");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.beforeData()).doesNotContain("platform.admin", "platform:customer:edit", "permissions");
} }
@Test @Test
@@ -57,7 +59,7 @@ class BusinessAuditAspectTest {
new UsernamePasswordAuthenticationToken(user, null, List.of())); new UsernamePasswordAuthenticationToken(user, null, List.of()));
TestOperations target = new TestOperations(); TestOperations target = new TestOperations();
Method method = TestOperations.class.getDeclaredMethod("update", long.class); Method method = TestOperations.class.getDeclaredMethod("update", AuthenticatedUser.class, long.class);
MethodSignature signature = mock(MethodSignature.class); MethodSignature signature = mock(MethodSignature.class);
when(signature.getMethod()).thenReturn(method); when(signature.getMethod()).thenReturn(method);
when(signature.getParameterNames()).thenReturn(null); when(signature.getParameterNames()).thenReturn(null);
@@ -65,7 +67,7 @@ class BusinessAuditAspectTest {
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
when(joinPoint.getSignature()).thenReturn(signature); when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target); when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{requestedTenantId}); when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
when(joinPoint.proceed()).thenReturn("ok"); when(joinPoint.proceed()).thenReturn("ok");
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class)); aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
@@ -76,8 +78,8 @@ class BusinessAuditAspectTest {
} }
private static final class TestOperations { private static final class TestOperations {
@BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureBefore = false, captureAfter = false) @BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureAfter = false)
public String update(long tenantId) { public String update(AuthenticatedUser user, long tenantId) {
return "ok"; return "ok";
} }
} }
@@ -8,16 +8,27 @@ import java.util.Arrays;
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。 * 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
*/ */
public enum ResultCode { public enum ResultCode {
/** 请求处理成功。 */
SUCCESS("200", "success", HttpStatus.OK), SUCCESS("200", "success", HttpStatus.OK),
/** 请求参数不符合接口要求。 */
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST), BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
/** 未登录、令牌无效或登录态已过期。 */
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED), UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
/** 已登录但没有操作权限。 */
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN), FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
/** 请求的资源不存在或不在可访问范围内。 */
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND), NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
/** 资源版本、幂等键或当前状态发生冲突。 */
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT), CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
/** 字段校验、格式校验或业务前置校验未通过。 */
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY), VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
/** 请求频率或并发数量超过限制。 */
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS), TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
/** 未被更细错误码覆盖的通用业务失败。 */
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST), BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
/** 服务内部发生未预期异常。 */
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR), INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
/** 下游服务、数据库或基础设施暂不可用。 */
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE); SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
private final String code; private final String code;
@@ -24,5 +24,18 @@ public final class ExportContracts {
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { } public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME } public enum ExportColumnType {
/** 文本列。 */
STRING,
/** 整数列。 */
INTEGER,
/** 高精度小数列。 */
DECIMAL,
/** 布尔值列。 */
BOOLEAN,
/** 日期列,不包含时间。 */
DATE,
/** 日期时间列。 */
DATETIME
}
} }
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
if (payload == null || payload.isNull()) { if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空"); throw new IllegalArgumentException("事件负载不能为空");
} }
return write(sort(payload));
}
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
public static byte[] canonicalizeLegacy(JsonNode payload) {
if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空");
}
return write(sortLegacy(payload));
}
private static byte[] write(JsonNode payload) {
try { try {
return OBJECT_MAPPER.writeValueAsBytes(sort(payload)); return OBJECT_MAPPER.writeValueAsBytes(payload);
} catch (JsonProcessingException exception) { } catch (JsonProcessingException exception) {
throw new IllegalArgumentException("事件负载无法规范化", exception); throw new IllegalArgumentException("事件负载无法规范化", exception);
} }
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
node.forEach(child -> result.add(sort(child))); node.forEach(child -> result.add(sort(child)));
return result; return result;
} }
if (node.isNumber()) {
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
}
return node;
}
private static JsonNode sortLegacy(JsonNode node) {
if (node.isObject()) {
var names = new java.util.ArrayList<String>();
node.fieldNames().forEachRemaining(names::add);
names.sort(String::compareTo);
var result = OBJECT_MAPPER.createObjectNode();
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
return result;
}
if (node.isArray()) {
var result = OBJECT_MAPPER.createArrayNode();
node.forEach(child -> result.add(sortLegacy(child)));
return result;
}
return node; return node;
} }
} }
@@ -9,6 +9,10 @@ public final class EventTypes {
public static final String ORDER_APPROVED = "OrderApproved"; public static final String ORDER_APPROVED = "OrderApproved";
public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
@@ -0,0 +1,74 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.util.List;
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
List<Order> orders) {
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, null, List.of());
}
}
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,18 @@
package com.baiyue.common.event;
import java.time.Instant;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 {
public static final int VERSION = 1;
private IamSecurityEventsV1() { }
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
String status, long permissionVersion, Instant changedAt) { }
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
long permissionVersion, String reason, Instant changedAt) { }
}
@@ -0,0 +1,29 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class EventPayloadCanonicalizerTest {
private final ObjectMapper json = new ObjectMapper();
@Test
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
}
@Test
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
var negativeZero = json.readTree("{\"value\":-0.000}");
var zero = json.readTree("{\"value\":0}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FirstLegLoadingPlanCompletedV1Test {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
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));
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
}
@Test
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"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",
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,
"3", "海外仓", List.of(order));
String json = mapper.writeValueAsString(payload);
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
}
}
@@ -0,0 +1,15 @@
package com.baiyue.common.security;
import java.util.Set;
/** Server-side access session and its current authorization snapshot. */
public record AccessTokenSession(int version, Long userId, Set<String> roles,
Set<String> permissions) {
public AccessTokenSession {
if (version != 1 || userId == null || userId <= 0) {
throw new IllegalArgumentException("Invalid access token session");
}
roles = roles == null ? Set.of() : Set.copyOf(roles);
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
}
}
@@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt;
*/ */
@FunctionalInterface @FunctionalInterface
public interface AccessTokenSessionValidator { public interface AccessTokenSessionValidator {
void validate(Jwt jwt); AccessTokenSession validate(Jwt jwt);
} }
@@ -8,7 +8,13 @@ import java.util.Set;
*/ */
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, String username, String appCode, String clientType,
Set<String> permissions) implements Principal { Set<String> permissions, boolean impersonated, Long actorUserId,
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
String username, String appCode, String clientType, Set<String> permissions) {
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
permissions, false, null, null, false);
}
public AuthenticatedUser { public AuthenticatedUser {
permissions = permissions == null ? Set.of() : Set.copyOf(permissions); permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
} }
@@ -18,4 +24,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI
return username; return username;
} }
} }
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
*/ */
@AutoConfiguration( @AutoConfiguration(
after = HmacJwtDecoderAutoConfiguration.class, after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
before = SecurityAutoConfiguration.class) before = SecurityAutoConfiguration.class)
@EnableMethodSecurity @EnableMethodSecurity
public class BaiyueSecurityAutoConfiguration { public class BaiyueSecurityAutoConfiguration {
@@ -47,10 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
@ConditionalOnMissingBean @ConditionalOnMissingBean
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
AuthenticationEntryPoint entryPoint, AuthenticationEntryPoint entryPoint,
StringRedisTemplate redisTemplate, org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { ObjectMapper objectMapper,
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix)); @Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else {
StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
}
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
} }
@Bean @Bean
@@ -0,0 +1,16 @@
package com.baiyue.common.security;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.LinkedHashSet;
import java.util.Set;
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
@Override public AccessTokenSession validate(Jwt jwt) {
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
}
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
}
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
*/ */
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
@ConditionalOnProperty(name = "baiyue.security.jwt-secret") @ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
public class HmacJwtDecoderAutoConfiguration { public class HmacJwtDecoderAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean(JwtDecoder.class) @ConditionalOnMissingBean(JwtDecoder.class)
@@ -0,0 +1,32 @@
package com.baiyue.common.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import java.util.List;
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
public class JwksJwtDecoderAutoConfiguration {
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
@Value("${baiyue.security.issuer:auth-service}") String issuer,
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(audience))));
return decoder;
}
}
@@ -27,7 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
AuthenticationEntryPoint authenticationEntryPoint) { AuthenticationEntryPoint authenticationEntryPoint) {
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { }); this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null);
} }
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
@@ -55,8 +55,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenValue = header.substring(BEARER.length()).trim(); String tokenValue = header.substring(BEARER.length()).trim();
try { try {
Jwt jwt = jwtDecoder.decode(tokenValue); Jwt jwt = jwtDecoder.decode(tokenValue);
sessionValidator.validate(jwt); AccessTokenSession session = sessionValidator.validate(jwt);
Authentication authentication = claimsMapper.map(jwt); Authentication authentication = claimsMapper.map(jwt, session);
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} catch (JwtException | IllegalArgumentException exception) { } catch (JwtException | IllegalArgumentException exception) {
@@ -13,28 +13,37 @@ import java.util.Set;
*/ */
public class JwtClaimsMapper { public class JwtClaimsMapper {
public BaiyueJwtAuthenticationToken map(Jwt jwt) { public BaiyueJwtAuthenticationToken map(Jwt jwt) {
Set<String> permissions = new LinkedHashSet<>(); return map(jwt, null);
addClaimValues(permissions, jwt.getClaims().get("authorities")); }
addClaimValues(permissions, jwt.getClaims().get("permissions"));
addClaimValues(permissions, jwt.getClaims().get("scope"));
addRoles(permissions, jwt.getClaims().get("roles"));
String appCode = jwt.getClaimAsString("app_code"); public BaiyueJwtAuthenticationToken map(Jwt jwt, AccessTokenSession session) {
Set<String> permissions = new LinkedHashSet<>();
if (session != null) {
permissions.addAll(session.permissions());
addRoles(permissions, session.roles());
}
String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code"));
if (appCode != null && !appCode.isBlank()) { if (appCode != null && !appCode.isBlank()) {
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT)); permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
} }
String username = firstNonBlank(jwt.getClaimAsString("username"), String username = firstNonBlank(jwt.getClaimAsString("username"),
jwt.getClaimAsString("preferred_username"), jwt.getSubject()); jwt.getClaimAsString("preferred_username"), jwt.getSubject());
Long userId = longClaim(jwt, "user_id"); Long userId = longClaim(jwt, "userId");
if (userId == null) userId = longClaim(jwt, "user_id");
AuthenticatedUser user = new AuthenticatedUser( AuthenticatedUser user = new AuthenticatedUser(
userId == null ? longValue(jwt.getSubject()) : userId, userId == null ? longValue(jwt.getSubject()) : userId,
longClaim(jwt, "tenant_id"), longClaimEither(jwt, "tenantId", "tenant_id"),
longClaim(jwt, "operator_tenant_id"), longClaim(jwt, "operator_tenant_id"),
longClaim(jwt, "membership_id"), longClaim(jwt, "membership_id"),
username, username,
jwt.getClaimAsString("app_code"), appCode,
jwt.getClaimAsString("client_type"), jwt.getClaimAsString("client_type"),
permissions); permissions,
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
longClaim(jwt, "actor_user_id"),
longClaim(jwt, "actor_membership_id"),
Boolean.TRUE.equals(jwt.getClaim("password_change_required")));
List<SimpleGrantedAuthority> authorities = permissions.stream() List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new).toList(); .map(SimpleGrantedAuthority::new).toList();
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
@@ -68,10 +77,13 @@ public class JwtClaimsMapper {
if (value instanceof String text) return longValue(text); if (value instanceof String text) return longValue(text);
return null; return null;
} }
private Long longClaimEither(Jwt jwt, String first, String second) {
Long value = longClaim(jwt, first);
return value == null ? longClaim(jwt, second) : value;
}
private String firstNonBlank(String... values) { private String firstNonBlank(String... values) {
for (String value : values) if (value != null && !value.isBlank()) return value; for (String value : values) if (value != null && !value.isBlank()) return value;
return "unknown"; return "unknown";
} }
} }
@@ -2,6 +2,7 @@ package com.baiyue.common.security;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import com.fasterxml.jackson.databind.ObjectMapper;
/** /**
* Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。 * Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。
@@ -9,24 +10,49 @@ import org.springframework.security.oauth2.jwt.Jwt;
public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator { public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator {
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final String keyPrefix; private final String keyPrefix;
private final ObjectMapper objectMapper;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
this(redisTemplate, keyPrefix, new ObjectMapper());
}
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
this.objectMapper = objectMapper;
} }
@Override @Override
public void validate(Jwt jwt) { public AccessTokenSession validate(Jwt jwt) {
String jwtId = jwt.getId(); String jwtId = jwt.getId();
if (jwtId == null || jwtId.isBlank()) { if (jwtId == null || jwtId.isBlank()) {
throw new IllegalArgumentException("Access token has no jti"); throw new IllegalArgumentException("Access token has no jti");
} }
try { try {
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) { String value = redisTemplate.opsForValue().get(RedisTokenKeys.access(keyPrefix, jwtId));
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Access token session is revoked or expired"); throw new IllegalArgumentException("Access token session is revoked or expired");
} }
AccessTokenSession session = objectMapper.readValue(value, AccessTokenSession.class);
Long userId = longClaim(jwt, "user_id");
if (userId == null || !userId.equals(session.userId())) {
throw new IllegalArgumentException("Access token session identity mismatch");
}
return session;
} catch (org.springframework.dao.DataAccessException exception) { } catch (org.springframework.dao.DataAccessException exception) {
throw new IllegalArgumentException("Token session store is unavailable", exception); throw new IllegalArgumentException("Token session store is unavailable", exception);
} catch (IllegalArgumentException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalArgumentException("Invalid access token session", exception);
} }
} }
private Long longClaim(Jwt jwt, String name) {
Object value = jwt.getClaims().get(name);
if (value instanceof Number number) return number.longValue();
if (value instanceof String text) try { return Long.valueOf(text); } catch (NumberFormatException ignored) { }
return null;
}
} }
@@ -1,2 +1,3 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration
@@ -27,13 +27,31 @@ class JwtClaimsMapperTest {
.claim("roles", List.of("ADMIN")) .claim("roles", List.of("ADMIN"))
.build(); .build();
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt); AccessTokenSession session = new AccessTokenSession(1, 42L,
java.util.Set.of("ADMIN"), java.util.Set.of("order:read", "order:create"));
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, session);
assertThat(authentication.getPrincipal().userId()).isEqualTo(42L); assertThat(authentication.getPrincipal().userId()).isEqualTo(42L);
assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L); assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L);
assertThat(authentication.getAuthorities()).extracting("authority") assertThat(authentication.getAuthorities()).extracting("authority")
.contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT"); .contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT");
} }
@Test
void ignoresAuthorizationClaimsInJwt() {
Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
.claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permissions", List.of("platform:forged"))
.claim("roles", List.of("FORGED")).build();
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt,
new AccessTokenSession(1, 42L, java.util.Set.of(), java.util.Set.of("platform:real")));
assertThat(authentication.getAuthorities()).extracting("authority")
.contains("platform:real", "APP_PLATFORM")
.doesNotContain("platform:forged", "ROLE_FORGED");
}
@Test @Test
void fallsBackToNumericSubjectForUserId() { void fallsBackToNumericSubjectForUserId() {
Jwt jwt = Jwt.withTokenValue("token") Jwt jwt = Jwt.withTokenValue("token")
@@ -49,4 +67,26 @@ class JwtClaimsMapperTest {
assertThat(authentication.getPrincipal().userId()).isEqualTo(99L); assertThat(authentication.getPrincipal().userId()).isEqualTo(99L);
assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM"); assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM");
} }
@Test
void mapsImpersonationAndForcedPasswordClaims() {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("99")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("tenant_id", 7L)
.claim("app_code", "MERCHANT")
.claim("impersonated", true)
.claim("actor_user_id", 42L)
.claim("actor_membership_id", 43L)
.claim("password_change_required", true)
.build();
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
assertThat(user.impersonated()).isTrue();
assertThat(user.actorUserId()).isEqualTo(42L);
assertThat(user.actorMembershipId()).isEqualTo(43L);
assertThat(user.passwordChangeRequired()).isTrue();
}
} }
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant; import java.time.Instant;
@@ -18,7 +19,10 @@ class RedisAccessTokenSessionValidatorTest {
@Test @Test
void acceptsActiveSession() { void acceptsActiveSession() {
StringRedisTemplate redis = mock(StringRedisTemplate.class); StringRedisTemplate redis = mock(StringRedisTemplate.class);
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}");
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
.validate(jwt("jti-1"))).doesNotThrowAnyException(); .validate(jwt("jti-1"))).doesNotThrowAnyException();
@@ -27,7 +31,9 @@ class RedisAccessTokenSessionValidatorTest {
@Test @Test
void rejectsRevokedSession() { void rejectsRevokedSession() {
StringRedisTemplate redis = mock(StringRedisTemplate.class); StringRedisTemplate redis = mock(StringRedisTemplate.class);
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn(null);
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
.validate(jwt("jti-1"))) .validate(jwt("jti-1")))
@@ -38,7 +44,9 @@ class RedisAccessTokenSessionValidatorTest {
@Test @Test
void failsClosedWhenRedisIsUnavailable() { void failsClosedWhenRedisIsUnavailable() {
StringRedisTemplate redis = mock(StringRedisTemplate.class); StringRedisTemplate redis = mock(StringRedisTemplate.class);
when(redis.hasKey("baiyue:auth:access:jti-1")) ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1"))
.thenThrow(new RedisConnectionFailureException("offline")); .thenThrow(new RedisConnectionFailureException("offline"));
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
@@ -47,9 +55,37 @@ class RedisAccessTokenSessionValidatorTest {
.hasMessageContaining("unavailable"); .hasMessageContaining("unavailable");
} }
@Test
void rejectsSessionForAnotherUser() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("identity mismatch");
}
@Test
void rejectsMalformedSessionJson() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid access token session");
}
private Jwt jwt(String jwtId) { private Jwt jwt(String jwtId) {
return Jwt.withTokenValue("token").header("alg", "none") return Jwt.withTokenValue("token").header("alg", "none")
.subject("1").claim("jti", jwtId).issuedAt(Instant.now()) .subject("1").claim("jti", jwtId).issuedAt(Instant.now())
.claim("user_id", 1L)
.expiresAt(Instant.now().plusSeconds(60)).build(); .expiresAt(Instant.now().plusSeconds(60)).build();
} }
} }
@@ -16,6 +16,7 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -60,8 +61,8 @@ public class GlobalExceptionHandler {
return validationResponse(errors, request); return validationResponse(errors, request);
} }
@ExceptionHandler({HttpMessageNotReadableException.class, MissingServletRequestParameterException.class, @ExceptionHandler({HttpMessageNotReadableException.class, MissingRequestHeaderException.class,
MethodArgumentTypeMismatchException.class}) MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class})
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception, HttpServletRequest request) {
return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request); return failure(ResultCode.BAD_REQUEST, ResultCode.BAD_REQUEST.message(), request);
} }
@@ -11,6 +11,7 @@ import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -46,6 +47,19 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code()); assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
} }
@Test
void missingRequiredRequestHeaderMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
Method method = GlobalExceptionHandlerTest.class.getDeclaredMethod("paging", long.class);
MethodParameter parameter = new MethodParameter(method, 0);
var response = handler.handleBadRequest(
new MissingRequestHeaderException("Idempotency-Key", parameter), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
}
@Test @Test
void validationDetailsAreIncludedInMessageAndData() { void validationDetailsAreIncludedInMessageAndData() {
MockHttpServletRequest request = new MockHttpServletRequest(); MockHttpServletRequest request = new MockHttpServletRequest();
+11
View File
@@ -4,3 +4,14 @@ spring:
username: ${AUTH_DB_USERNAME} username: ${AUTH_DB_USERNAME}
password: ${AUTH_DB_PASSWORD} password: ${AUTH_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
security:
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:true}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
+1
View File
@@ -55,6 +55,7 @@ logging:
path: ${LOG_PATH:/app/logs} path: ${LOG_PATH:/app/logs}
server: server:
shutdown: graceful shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
management: management:
endpoints: endpoints:
web: web:
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${FINANCE_DB_URL}
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+3 -1
View File
@@ -15,10 +15,12 @@ firstleg:
warehouse-resource-tenant-id: 0 warehouse-resource-tenant-id: 0
measurement-openapi: measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
# 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。
signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps: apps:
# 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。 # 墨家设备群共用固定 AppKey;签名恢复后再启用 Secret。每台机器用 data.deviceCode 区分。
mojia: mojia:
enabled: ${MOJIA_MEASUREMENT_ENABLED:false} enabled: ${MOJIA_MEASUREMENT_ENABLED:false}
secret: ${MOJIA_MEASUREMENT_SECRET:} secret: ${MOJIA_MEASUREMENT_SECRET:}
+5
View File
@@ -4,3 +4,8 @@ spring:
username: ${IAM_DB_USERNAME} username: ${IAM_DB_USERNAME}
password: ${IAM_DB_PASSWORD} password: ${IAM_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}
+3 -1
View File
@@ -50,7 +50,9 @@ spring:
logging: logging:
file: file:
path: ${LOG_PATH:./logs} path: ${LOG_PATH:./logs}
server.shutdown: graceful server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
management: management:
endpoints.web.exposure.include: health,info,prometheus endpoints.web.exposure.include: health,info,prometheus
endpoint.health: endpoint.health:
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
+3 -1
View File
@@ -14,10 +14,12 @@ firstleg:
warehouse-resource-tenant-id: 0 warehouse-resource-tenant-id: 0
measurement-openapi: measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
# 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。
signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps: apps:
# 墨家设备群共用一组固定 AppKey/Secret;每台机器用 data.deviceCode 区分。 # 墨家设备群共用固定 AppKey;签名恢复后再启用 Secret。每台机器用 data.deviceCode 区分。
mojia: mojia:
enabled: ${MOJIA_MEASUREMENT_ENABLED:false} enabled: ${MOJIA_MEASUREMENT_ENABLED:false}
secret: ${MOJIA_MEASUREMENT_SECRET:} secret: ${MOJIA_MEASUREMENT_SECRET:}
+5
View File
@@ -3,3 +3,8 @@ spring.datasource:
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${IAM_DB_USERNAME:root} username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456} password: ${IAM_DB_PASSWORD:123456}
baiyue:
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}
+15
View File
@@ -19,6 +19,8 @@ x-common: &common
REDIS_DATABASE: ${REDIS_DATABASE:-0} REDIS_DATABASE: ${REDIS_DATABASE:-0}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events}
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false} AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false}
AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false} AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false}
REGION_CODE: ${REGION_CODE:-CN} REGION_CODE: ${REGION_CODE:-CN}
@@ -85,6 +87,19 @@ services:
FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME}
FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD}
finance:
<<: *common
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9203
FINANCE_DB_URL: ${FINANCE_DB_URL}
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
order: order:
<<: *common <<: *common
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
+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"> xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors> <mirrors>
<mirror> <mirror>
<id>aliyun-public</id> <id>baiyue-nexus-public</id>
<name>阿里云 Maven 公共仓库</name> <name>柏悦 Nexus Maven 聚合仓库</name>
<url>https://maven.aliyun.com/repository/public</url> <url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>central</mirrorOf> <mirrorOf>external:*</mirrorOf>
</mirror> </mirror>
</mirrors> </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> <profiles>
<profile> <profile>
<id>国内仓库</id> <id>柏悦 Nexus</id>
<repositories> <repositories>
<repository> <repository>
<id>central</id> <id>baiyue-nexus-public</id>
<url>https://repo.maven.apache.org/maven2</url> <url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases> <releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots> <snapshots><enabled>true</enabled></snapshots>
</repository> </repository>
</repositories> </repositories>
<pluginRepositories> <pluginRepositories>
<pluginRepository> <pluginRepository>
<id>central</id> <id>baiyue-nexus-public</id>
<url>https://repo.maven.apache.org/maven2</url> <url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases> <releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots> <snapshots><enabled>true</enabled></snapshots>
</pluginRepository> </pluginRepository>
</pluginRepositories> </pluginRepositories>
</profile> </profile>
</profiles> </profiles>
<activeProfiles> <activeProfiles>
<activeProfile>国内仓库</activeProfile> <activeProfile>柏悦 Nexus</activeProfile>
</activeProfiles> </activeProfiles>
</settings> </settings>
+11
View File
@@ -27,6 +27,17 @@ env_file_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
} }
case " $SERVICES " in
*" finance "*)
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
;;
esac
case " $SERVICES " in case " $SERVICES " in
*" file "*) *" file "*)
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
+4
View File
@@ -43,3 +43,7 @@ services:
artifactId: audit-service artifactId: audit-service
jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar
port: 9700 port: 9700
finance:
artifactId: finance-service
jar: finance/target/finance-service-1.0.0-SNAPSHOT.jar
port: 9203
@@ -1,61 +0,0 @@
-- 可靠审计 Outbox、消费幂等和死信补偿增量(MySQL 8)
SET NAMES utf8mb4;
USE baiyue_audit;
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
DELIMITER $$
CREATE PROCEDURE create_reliable_audit_outbox(IN schema_name VARCHAR(64))
BEGIN
SET @ddl = CONCAT(
'CREATE TABLE IF NOT EXISTS \`', schema_name, '\`.sys_audit_outbox (',
'event_id VARCHAR(64) NOT NULL,event_type VARCHAR(64) NOT NULL,resource_type VARCHAR(64) NOT NULL,',
'resource_id VARCHAR(128) NULL,service_name VARCHAR(64) NULL,payload_json JSON NOT NULL,',
'status VARCHAR(16) NOT NULL DEFAULT ''PENDING'',attempt_count INT NOT NULL DEFAULT 0,',
'compensation_count INT NOT NULL DEFAULT 0,next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),',
'lease_owner VARCHAR(128) NULL,lease_until DATETIME(3) NULL,last_error VARCHAR(1000) NULL,',
'dead_at DATETIME(3) NULL,sent_at DATETIME(3) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),',
'updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),',
'PRIMARY KEY(event_id),KEY idx_audit_outbox_publish(status,next_retry_at,lease_until,created_at),',
'KEY idx_audit_outbox_retention(status,sent_at)) ENGINE=InnoDB COMMENT=''可靠审计事件本地发送箱'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END$$
DELIMITER ;
CALL create_reliable_audit_outbox('baiyue_auth');
CALL create_reliable_audit_outbox('baiyue_iam');
CALL create_reliable_audit_outbox('baiyue_order');
CALL create_reliable_audit_outbox('baiyue_firstleg');
CALL create_reliable_audit_outbox('baiyue_warehouse');
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
USE baiyue_audit;
DROP PROCEDURE IF EXISTS add_audit_event_id;
DELIMITER $$
CREATE PROCEDURE add_audit_event_id()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id') THEN
ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id') THEN
ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id(event_id);
END IF;
END$$
DELIMITER ;
CALL add_audit_event_id();
DROP PROCEDURE IF EXISTS add_audit_event_id;
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT,event_id VARCHAR(64) NOT NULL,payload_text LONGTEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,last_error VARCHAR(1000) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),processed_at DATETIME(3) NULL,
PRIMARY KEY(id),UNIQUE KEY uk_audit_dead_event(event_id),
KEY idx_audit_dead_retry(status,next_retry_at,lease_until)
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
SELECT 'PASS' AS migration_status,'可靠审计Outbox、幂等和死信补偿表已就绪' AS message;
+13 -3
View File
@@ -9,7 +9,9 @@
- 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。
- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 8 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`。Gateway、Print、Integration Service 无数据库。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
后续追加增量必须满足: 后续追加增量必须满足:
@@ -20,6 +22,13 @@
4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。
5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。
## Finance 首期人工财务基线
- Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。
- 首期只支持 CNY,包含平台主体、客户默认 30 天账期、账单及明细、人工收款及账单分配、单票/整柜人工成本、体积分摊结果、Firstleg 费用来源事件、幂等记录和不可变审计日志。
- 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。
- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。
## 历史一期回归脚本 ## 历史一期回归脚本
- 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。 - 历史文件:`2026-07-31一期回归测试环境增量迁移.sql`,仅供追溯,不再作为统一入口。
@@ -55,7 +64,8 @@
- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。 - 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。 - 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。 - 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。
- 统一脚本会创建 `fl_preload_plan`、`fl_split_operation`、`fl_loading_evidence`、`fl_loading_operation`、`fl_loading_pick_task`,并补齐 9 项平台操作权限。新增权限默认只授予平台系统管理员,其他角色由平台权限管理按职责配置。 - 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是二期有效终点,脚本不会改写主表或状态历史。
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
部署前可只读盘点: 部署前可只读盘点:
@@ -124,7 +134,7 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready
## 当前主体字段语义核验 ## 当前主体字段语义核验
- 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目和入库费用明细沿用该物理字段名。 - 业务单租户阶段,业务表中的 `tenant_id > 0` 是客户 ID 和客户隔离键,不表示上级租户;费用项目是 `tenant_id = 0` 的平台公共主数据,入库费用明细仍以 `tenant_id > 0` 隔离客户并引用公共 `fee_item_id`。
- `fl_third_party_call_log.tenant_id = 0` 仅表示第三方主体未知或尚未解析的哨兵值,不是平台客户记录或平台操作作用域,绝不能据此授权、查询或写入客户业务数据。上线后可只读统计: - `fl_third_party_call_log.tenant_id = 0` 仅表示第三方主体未知或尚未解析的哨兵值,不是平台客户记录或平台操作作用域,绝不能据此授权、查询或写入客户业务数据。上线后可只读统计:
```sql ```sql
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
# 二期、三期业务功能回归报告
测试时间:2026-08-07
## 测试范围
- 二期:企业资质、平台审核、公共主数据、客户计费规则、头程下单、入库测量、称重、计费与入库。
- 三期:虚拟柜、智能装柜、预装、PDA 拣货和最终装柜清单。
- 测试环境:本机 MySQL、Redis、Nacos;隔离服务端口 `19300` 到 `19306`。未停止、未占用用户的 `192xx` 服务端口。
- 测试结束后已停止本次启动的 Gateway 和全部 `193xx` 隔离服务;未停止 MySQL、Redis、Nacos 或用户服务。
## 项目索引结论
- 二期主状态和测量、计费、入库编排在 `firstleg`;订单货物在 `order`;仓库、托盘、库位、往来单位在 `warehouse`;文件在 `file`。
- 三期智能装柜、预装、PDA 拣货、装柜清单均在 `firstleg`。
- 当前 `tenant_id` 在业务上代表客户归属;公共主数据应使用 `tenant_id=0`,订单服务仅可读取公共仓库和公共往来单位。
## 二期结果
结果:未通过,主链路在创建头程订单草稿前被阻断。
- 已通过 15 项:平台/商户登录、Firstleg 健康检查、企业注册、营业执照及法人身份证上传、企业资质提交与平台审核、承运商与客户往来单位创建、收货仓创建、客户计费规则创建、费率版本创建。
- 阻断接口:`POST /api/merchant/v1/orders/first-leg`。
- 返回:业务码 `A0404`,提示往来单位不存在或不属于当前企业。
### 阻断原因
1. 订单服务 `OrderMasterDataGuard` 固定以公共归属 `tenantId=0` 查询往来单位和收货仓,并要求订单客户快照往来单位具有 `SHIPPER` 类型。
2. 当前平台往来单位 API 对 `tenantId=0` 返回 `A0422`,校验信息为“最小不能小于 1”;公共主数据无法经 API 创建或查询。
3. 旧订单请求仍强制 `carrierId`、`carrierName`。本次不传时返回 `A0422`。根据最新业务确认,头程业务不存在“承运商”概念,这一历史字段、校验、分配接口和费率依赖应移除或改为船司领域模型,不能继续作为下单前置条件。
结论:在修复公共主数据 `tenantId=0` API 契约,并移除订单承运商强制校验前,二期无法从下单继续验证测量、称重、计费和入库。
## 三期结果
结果:通过。
真实 API 已复核的完成装柜清单:
- 装柜计划:`2085554481377796097`
- 查询接口:`GET /api/platform/v1/first-leg/loading-lists/2085554481377796097?warehouseId=2084548444218646530`
- 最终状态:`COMPLETED`
- 集装箱号:`PH3-20260807103117`
- 装柜箱数:2
- 总件数:20
- 总毛重:20kg
- 总体积:0.24m3
- 历史事件存在:`PDA_COMPLETE_LOADING`
- 明细已有冻结时间,符合完成后不可修改的规则。
定向服务层测试:29 项通过、0 失败、0 错误。
- `SmartLoadingServiceTest`:15 项
- `LoadingListServiceTest`:8 项
- `LoadingPickServiceTest`:2 项
- `PdaPickSettlementPlanTest`:3 项
- `SmartLoadingStatusTest`:1 项
## 构建与脚本观察
- 离线全量模块测试执行到 `firstleg`:211 项中 1 项失败。失败项是 `InboundParentChildSqlContractTest`,原因是统一 SQL 已使用最终索引命名,但测试仍断言旧索引名 `uk_inbound_parent_sequence`;属于 SQL 契约断言滞后,不是 Java 业务断言失败。
- 二期验收脚本需要 PowerShell 7 的 `SkipHttpErrorCheck`,而本机只有 Windows PowerShell 5;测试时使用了临时 UTF-8 BOM 兼容副本。
- 原脚本引用的 `c7.png`、`c8.png` 示例图片未随项目提供;测试目录使用最小 PNG 仅验证上传协议。
- 初始 File 隔离 JAR 未包含当前资质包上传端点,使用当前编译类启动后接口通过。发布前应重新打包 File 服务,避免运行包与源码不一致。
## 建议处理顺序
1. 移除头程订单及二期计费中的承运商强制字段、校验和分配逻辑;如需要船司,改为独立船司主数据,不复用往来单位承运商。
2. 修复平台公共主数据 API:允许且仅允许平台身份使用 `tenantId=0` 创建、查询公共仓库和往来单位。
3. 为订单下单提供可选的公共发货单位选择,或明确订单客户快照不再依赖 `SHIPPER` 往来单位。
4. 完成以上两项后,重新执行二期全链路:下单、审核、入仓单、测量、OCR 称重、费用试算、费用确认、入库完成。
@@ -223,7 +223,7 @@ deploy/
推荐顺序: 推荐顺序:
1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。 1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。
2. 核心认证:IAM、Auth。 2. 核心认证:Auth、IAM。
3. 主数据与适配:Warehouse、Integration。 3. 主数据与适配:Warehouse、Integration。
4. 业务依赖:Order、File。 4. 业务依赖:Order、File。
5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。 5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。
@@ -231,6 +231,39 @@ deploy/
Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。 Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。
### Auth/IAM RBAC 会话撤销发布验证
角色启停或角色权限全量变更会由 IAM 调用 Auth 的
`POST /internal/auth/accounts/sessions/revoke`,批量撤销受影响成员的 Access/Refresh 会话。
该契约发布时必须先部署 Auth,再部署 IAM,不得只更新 IAM。
发布后至少完成以下验证:
1. 确认 Auth、IAM 使用同一提交对应的镜像版本,且 IAM 能通过服务发现和内部服务令牌访问上述 Auth 内部接口;404、连接失败、内部令牌不一致或非成功业务码均不得放行 IAM 角色变更。
2. 为测试成员分配带订单查看权限的自定义角色,成员登录并确认订单列表可访问。
3. 停用该角色必须返回业务成功;继续使用停用前的 Access Token 访问订单列表必须被拒绝,旧 Refresh Token 也必须失效。
4. 成员重新登录后权限集合应为空;重新启用角色并再次登录后,原角色权限应恢复。
5. 修改角色权限集合后重复验证旧 Access/Refresh Token 失效,避免 `grantPermissions` 路径遗留旧权限。
2026-08-08 测试环境曾出现版本不一致:停用已分配角色返回 HTTP 200、业务码 `S0503`,
提示“认证会话撤销失败,角色变更未生效”。证据报告为
`reports/client-test-env-20260808021636-b5090589.json`,`requestId=client-e2e-d9e860c8-b061-43fd-b67b-196f76d95b3d`,
`traceId=f2812578869349b1b4393421498d05df`。该结果说明 IAM 已包含会话撤销调用,但 Auth
内部接口尚未部署或不可达;必须在 Auth 先行部署并通过连通性检查后重新执行上述验收。
本轮 Client 扩展测试的发布前最终基准为
`reports/client-test-env-20260808022313-1798dc6c.json`:共 150 项,143 通过、5 失败、2 跳过。
5 个阻断项分别为角色停用和角色权限收缩时 Auth/IAM 版本不一致导致 `S0503`、清空成员角色未撤销旧 JWT、
缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、
已提交订单编辑 `A0409`、密码恢复和报告脱敏均已通过。后续发布验证应以该报告为对照,确认上述 5 项全部关闭。
部署完成后的最终验收报告为 `reports/client-test-env-20260808032107-59bca758.json`:
163 项全部通过,0 失败、0 跳过,`success=true`。发布前 5 个失败项和 2 个依赖跳过项均已关闭,
包括角色停用、角色权限收缩和成员角色清空后的旧会话撤销,缺少 `Idempotency-Key` 返回 `A0400`,
以及跨客户资质文件读取返回 `A0404`。角色停用后,旧 Token 查询实时权限也必须返回
HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新会话查询停用角色的有效权限时才返回空集合。
账号原密码恢复和报告脱敏检查同时通过。本报告作为本轮 Client 全量 HTTP 验收的最终通过基线。
## 9. 开发和拆分约束 ## 9. 开发和拆分约束
- 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。 - 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。
+186
View File
@@ -0,0 +1,186 @@
# 系统状态枚举说明
## 使用范围
本文档说明当前后端实际使用的状态值。状态值是接口、数据库和前端联调契约,不能为了展示中文而修改其英文编码;前端展示应使用本说明中的中文含义。
代码中的 Java 枚举、状态常量和数据库状态字段均已补充中文注释。本说明用于集中查询,不替代各业务状态机的校验。
## 通用响应码
| 状态值 | 中文含义 |
| --- | --- |
| `200` | 请求成功。 |
| `A0400` | 请求参数不符合接口要求。 |
| `A0401` | 未登录、令牌无效或会话过期。 |
| `A0403` | 已登录但没有权限。 |
| `A0404` | 资源不存在或不在可访问范围。 |
| `A0409` | 状态、版本或幂等键冲突。 |
| `A0422` | 字段或业务前置校验失败。 |
| `A0429` | 请求频率或并发数量超限。 |
| `B0001` | 通用业务失败。 |
| `S0500` | 服务内部异常。 |
| `S0503` | 下游服务、数据库或基础设施暂不可用。 |
## 账号和权限
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 账号状态 | `ACTIVE` | 账号正常,可登录。 |
| 账号状态 | `DISABLED` | 账号已停用,不允许登录。 |
| 账号状态 | `LOCKED` | 账号已锁定,需要管理员解除。 |
| 客户资料状态 | `PROFILE_REQUIRED` | 客户资料尚未补全。 |
| 客户资料状态 | `PENDING` | 客户资料已提交,等待审核。 |
| 客户资料状态 | `ACTIVE` | 客户审核通过,可正常开展业务。 |
| 客户资料状态 | `REJECTED` | 客户资料审核驳回。 |
| 客户资料状态 | `DISABLED` | 客户已停用。 |
| 客户、成员、角色、权限状态 | `ACTIVE` | 已启用,可参与登录、授权或业务使用。 |
| 客户、成员、角色、权限状态 | `DISABLED` | 已停用,不可继续使用。 |
| 审核记录状态 | `PENDING` | 等待审核。 |
| 审核记录状态 | `APPROVED` | 审核通过。 |
| 审核记录状态 | `REJECTED` | 审核驳回。 |
| 审核记录状态 | `SUPERSEDED` | 已被更新后的审核记录替代。 |
## 订单状态
| 状态值 | 中文含义 |
| --- | --- |
| `DRAFT` | 草稿,可继续编辑。 |
| `SUBMITTED` | 已提交,等待平台审核。 |
| `APPROVED` | 审核通过,可接单或进入履约。 |
| `ACCEPTED` | 平台已接单。 |
| `REJECTED` | 审核驳回,不允许进入履约。 |
| `CANCELLED` | 已取消,订单结束。 |
| `PROCESSING` | 履约处理中。 |
| `COMPLETED` | 订单完成。 |
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING`、`LOADED`、`DELIVERED`。
## 仓库与库位
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 仓库、往来单位、快递公司 | `ACTIVE` | 启用,可被下单和作业引用。 |
| 仓库、往来单位、快递公司 | `INACTIVE` | 停用,不允许新增业务引用。 |
| 托盘 | `AVAILABLE` | 可分配。 |
| 托盘 | `RESERVED` | 已预占,尚未确认占用。 |
| 托盘 | `OCCUPIED` | 已确认占用。 |
| 库位 | `AVAILABLE` | 可用。 |
| 库位 | `RESERVED` | 已预占。 |
| 库位 | `OCCUPIED` | 已占用。 |
| 库位 | `DISABLED` | 已停用,不允许分配。 |
| 库位预占记录 | `RESERVED` | 预占生效中。 |
| 库位预占记录 | `CONFIRMED` | 预占已转为正式占用。 |
| 库位预占记录 | `RELEASED` | 已释放。 |
| 库位预占记录 | `EXPIRED` | 预占超时失效。 |
| 库位占用记录 | `OCCUPIED` | 正在占用。 |
| 库位占用记录 | `RELEASED` | 已释放。 |
## 二期入库
### 入库单主流程状态
| 状态值 | 中文含义 |
| --- | --- |
| `WAIT_MEASURE` | 等待录入或接收测量数据。 |
| `MEASURING` | 正在测量、识别重量或人工修正。 |
| `MEASURE_PENDING` | 测量已提交,等待审核。 |
| `MEASURE_REJECTED` | 测量审核被驳回,等待修正或异常放行。 |
| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
| `WAIT_SPLIT` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
| `LOADED` | 装柜完成,等待派送完成。 |
| `DELIVERED` | 已完成派送。 |
| `VOIDED` | 已作废,不允许继续处理。 |
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
### 测量、重量和费用状态
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 测量批次 | `DRAFT` | 后台人工测量草稿,可编辑。 |
| 测量批次 | `RECEIVED` | 已接收第三方测量回传,待平台提交审核。 |
| 测量批次 | `SUBMITTED` | 已提交测量审核。 |
| 测量批次 | `APPROVED` | 测量审核通过。 |
| 测量批次 | `REJECTED` | 测量审核驳回。 |
| 重量识别 | `PENDING_CONFIRM` | OCR 已识别有效数值,等待人工确认。 |
| 重量识别 | `NEEDS_MANUAL` | OCR 无效或不完整,必须人工录入或修正。 |
| 重量识别 | `CONFIRMED` | 重量已确认,可参与计费。 |
| 测量审核、费用审核 | `NOT_SUBMITTED` | 尚未提交审核。 |
| 测量审核、费用审核 | `PENDING` | 等待审核。 |
| 测量审核、费用审核 | `APPROVED` | 审核通过。 |
| 测量审核、费用审核 | `REJECTED` | 审核驳回。 |
| 费用状态 | `UNPRICED` | 尚未计费。 |
| 费用状态 | `CALCULATED` | 已计算费用,等待确认。 |
| 费用状态 | `CONFIRMED` | 费用已确认,可发送给客户。 |
| 费用状态 | `SENT` | 费用明细已发送给客户。 |
| 客户通知状态 | `NOT_CREATED` | 尚未创建通知任务。 |
| 客户通知状态 | `PENDING` | 已创建待处理通知。当前代码未发现发送完成后的独立状态更新。 |
### 第三方回传和幂等记录
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 第三方测量调用 | `PROCESSING` | 请求正在处理。 |
| 第三方测量调用 | `ACCEPTED` | 回传被接受,包含幂等重放成功。 |
| 第三方测量调用 | `REJECTED` | 回传校验或处理失败。 |
| 入库与计费幂等记录 | `PROCESSING` | 请求正在执行。 |
| 入库与计费幂等记录 | `COMPLETED` | 请求已成功完成,可返回幂等结果。 |
## 三期智能装柜和 PDA
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 货柜聚合 | `WAIT_LOADING` | 待装柜。货柜已创建但尚未创建装柜计划的派生状态,只在查询结果中返回,不写入装柜计划表。 |
| 装柜计划 | `EDITING` | 预装柜中,可维护货柜信息,并可增删未拣货明细。 |
| 装柜计划 | `LOADING` | 拣货中,已生成拣货任务;未拣货明细仍可追加、修改或删除。 |
| 装柜计划 | `COMPLETED` | 装柜完成。拣货完成后自动完成,计划、明细和货柜信息只读。 |
| PDA 拣货任务 | `LOADING` | 拣货作业进行中。 |
| PDA 拣货任务 | `COMPLETED` | 拣货已完成。 |
| PDA 拣货明细 | `PENDING` | 尚未开始拣货。 |
| PDA 拣货明细 | `PICKING` | 已拣部分数量,仍可继续拣货。 |
| PDA 拣货明细 | `PICKED` | 计划箱数已全部拣完。 |
| PDA 拣货明细 | `PARTIAL` | 只拣到部分箱数,剩余数量需要生成关联子入库单。 |
| PDA 拣货明细 | `SKIPPED` | 本次跳过,入库单回退为已入库并可重新参与智能装柜。 |
| 预装清单 | `PENDING` | 预装草稿,尚未开始确认。 |
| 预装清单 | `CONFIRMING` | 正在确认预装明细。 |
| 预装清单 | `CONFIRMED` | 预装已确认,可进入装柜作业。 |
| 新版拆单批次 | `PREPARED` | 已准备待应用。 |
| 新版拆单批次 | `APPLIED` | 已应用并生成关联子入库单。 |
| 新版拆单批次 | `CANCELLED` | 已取消,不能再次应用。 |
| 旧版拆单记录 | `LOCKED` | 已锁定待完成。 |
| 旧版拆单记录 | `COMPLETED` | 已完成并关联子入库单。 |
货柜列表状态为:`WAIT_LOADING`(待装柜)-> `EDITING`(预装柜中)-> `LOADING`(拣货中)-> `COMPLETED`(装柜完成)。其中 `WAIT_LOADING` 是货柜没有装柜计划时的派生状态;装柜计划持久化状态只能单向推进:`EDITING` -> `LOADING` -> `COMPLETED`,没有撤回状态。`COMPLETED` 后不允许编辑或删除装柜明细。
## 文件、导出和审计
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 导出任务 | `PENDING` | 等待执行。 |
| 导出任务 | `RUNNING` | 正在生成文件。 |
| 导出任务 | `SUCCEEDED` | 文件已生成,可下载。 |
| 导出任务 | `FAILED` | 生成失败,可按规则重试。 |
| 导出任务 | `EXPIRED` | 文件已过期。 |
| 导出消息 | `PENDING` | 等待投递。 |
| 导出消息 | `CLAIMED` | 已被工作线程领取。 |
| 导出消息 | `SENT` | 已成功投递。 |
| 导出消息 | `QUARANTINED` | 异常隔离,等待人工或补偿处理。 |
| 审计消息 | `PENDING` | 等待发送。 |
| 审计消息 | `SENDING` | 正在发送。 |
| 审计消息 | `SENT` | 已发送成功。 |
| 审计消息 | `DEAD` | 多次失败,进入死信补偿。 |
## 第三方能力和 OCR
| 字段 | 状态值 | 中文含义 |
| --- | --- | --- |
| 第三方能力 | `DISABLED` | 能力未启用。 |
| 第三方能力 | `NOT_CONFIGURED` | 已启用但密钥或配置不完整。 |
| 第三方能力 | `READY` | 已启用且配置完整。 |
| 单图 OCR | `RECOGNIZED` | 已识别到有效重量。 |
| 单图 OCR | `NO_WEIGHT_FOUND` | 已处理图片但未识别到可用重量。 |
| 单图 OCR | `FAILED` | 图片处理、供应商调用或解析失败。 |
@@ -35,7 +35,9 @@ public class EventInboxService {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
} }
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
} }
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本', aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本', aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
+20 -44
View File
@@ -1,52 +1,28 @@
# File Service 模块指引 # File Service 模块指引
## 职责边界 ## 职责与边界
- File 服务是企业资质文件的唯一公开上传入口。必须先持久化并验证真实文件,再调用 IAM 内部接口登记元数据;禁止只生成或改写一个不存在文件的 URL。 - 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
- 负责企业资质文件、公共导出文件和二期入库测量图片的上传、存储、读取及访问鉴权;当前文件内容使用本地磁盘,不能进入 Git。 ## 模块入口
- `src/main/resources/db/file.sql` 中的 `file_object` 已由测量图片链路通过 `FileObjectEntity`、`FileObjectMapper` 接入,导出任务表也已接入;企业资质文件元数据仍归 IAM,不能把不同文件链路的持久化状态混为一谈。
- 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。
- 入库单和测量状态归 `firstleg/`,重量识别适配归 `integration-service/`;File 只保存图片对象、返回逻辑文件 ID 并代理受控 OCR 调用。
## 快速索引 | 目标 | 入口 |
| 功能 | 入口 |
|---|---| |---|---|
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` | | 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` | | 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` | | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 服务状态 | `controller/FileHealthController.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` | | 下游 OCR | `client/WeightOcrClient.java` |
| 公共导出 API | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 可靠调度 | `service/ExportStateService.java`、`mq/ExportOutboxPublisher.java`、`mq/ExportCommandConsumer.java`、`service/ExportReconciler.java` | | 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
| 通用 Provider/XLSX | `client/ExportProviderClient.java`、`service/GenericXlsxExportRunner.java`、公共契约 `../common-core/.../export/ExportContracts.java` |
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 平台称重图片上传、OCR 重试、原图与缩略图查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
| 测量图片测试 | `controller/PlatformMeasurementImageControllerContractTest.java`、`InternalMeasurementImageControllerTest.java`、`service/MeasurementImageServiceEvidenceTest.java` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
## 修改与验证 ## 修改约束
- 严格校验客户归属、文件名/路径、大小、类型和读取权限,防止目录穿越、跨客户访问和任意文件上传;接口和存储路径中的 `tenantId`、`tenant_id` 当前是客户隔离兼容字段。 - 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。 - 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。 ## 全量验收导航
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;入库称重图片和第三方尺寸证据均由其业务服务确认引用关系后通过内部接口逻辑删除,其他客户文件不得使用该接口删除。 一期、二期、三期及财务全量验收均复用本模块文件能力;按本次链路读取对应阶段/专题文档,统一核对资质、测量图片、装柜证据、报关回执的归属、鉴权、审计和生命周期。照片允许追加、错误记录仅作废不硬删除,客户不得读取内部照片或锁码。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录兼容字段 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标客户安全清理旧 UUID 文件。
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
@@ -0,0 +1,87 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.InternalBusinessFileService;
import jakarta.annotation.Resource;
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.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/file/v1/business-files")
public class InternalBusinessFileController {
@Resource private InternalBusinessFileService service;
@Value("${baiyue.security.internal-service-token}") private String expected;
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<InternalBusinessFileService.Metadata> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId,
@RequestPart("file") MultipartFile file) {
check(token);
checkTenantId(tenantId);
return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
public ApiResponse<InternalBusinessFileService.Metadata> metadata(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId,
@PathVariable @Positive long fileId) {
check(token);
checkTenantId(tenantId);
return ApiResponse.success(service.metadata(tenantId, businessType, businessId, fileId));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId,
@PathVariable @Positive long fileId) {
check(token);
checkTenantId(tenantId);
var value = service.download(tenantId, businessType, businessId, fileId);
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", "");
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
.contentLength(value.content().length)
.body(value.content());
}
private void check(String token) {
if (token == null || !MessageDigest.isEqual(
token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
private static void checkTenantId(long tenantId) {
if (tenantId < 0) throw new BizException(ResultCode.BAD_REQUEST, "客户作用域不能为负数");
}
}
@@ -56,6 +56,21 @@ public class InternalMeasurementImageController {
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file)); images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
} }
@PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> uploadReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录和操作主体ID必须大于0");
}
return ApiResponse.success(images.uploadMojiaReceiptEvidence(
tenantId, receiptId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}") @DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete( public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -71,6 +86,21 @@ public class InternalMeasurementImageController {
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}")
public ApiResponse<Void> deleteReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录、文件和操作主体ID必须大于0");
}
images.deleteMojiaReceiptEvidence(tenantId, receiptId, fileId, operatorId);
return ApiResponse.success(null);
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}") @GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ResponseEntity<byte[]> download( public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -102,7 +102,7 @@ public class PlatformQualificationFileController {
} }
@GetMapping("/{tenantId}/{fileName}") @GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:view')") @PreAuthorize("hasAuthority('platform:customer:list')")
public ResponseEntity<org.springframework.core.io.Resource> download( public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) { @PathVariable long tenantId, @PathVariable String fileName) {
@@ -0,0 +1,115 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Component
public class BusinessFileStorage {
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png");
@Value("${baiyue.file.storage-root}")
private Path configuredRoot;
private Path root;
@PostConstruct
void initializeRoot() {
root = configuredRoot.toAbsolutePath().normalize();
}
Stored storeLoading(long tenantId, long businessId, MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
String extension = TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type);
String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
return new Stored(objectKey, type, file.getSize(), sha256(target), name);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "附件保存失败");
}
}
byte[] read(String objectKey) {
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
return Files.readAllBytes(target);
} catch (IOException exception) {
throw new BizException(ResultCode.NOT_FOUND, "附件内容不存在");
}
}
private void verifyContent(MultipartFile file, String type) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4096);
if ("application/pdf".equals(type)) {
if (prefix.length < 5 || !new String(prefix, 0, 5, StandardCharsets.US_ASCII).equals("%PDF-")) {
throw bad("上传内容不是有效PDF");
}
} else if ("text/plain".equals(type)) {
for (byte value : prefix) if (value == 0) throw bad("文本附件包含非法二进制内容");
} else if (prefix.length < 8 || !validImageSignature(prefix, type)) {
throw bad("上传内容不是有效图片");
}
} catch (IOException exception) {
throw bad("附件内容读取失败");
}
}
private static boolean validImageSignature(byte[] bytes, String type) {
if ("image/png".equals(type)) {
return (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47;
}
return (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8;
}
private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString();
}
private static String sha256(Path path) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
record Stored(String objectKey, String contentType, long size, String sha256, String originalName) {}
}
@@ -0,0 +1,119 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.Set;
@Service
public class InternalBusinessFileService {
private static final Set<String> BUSINESS_TYPES = Set.of(
"TRUCK_APPOINTMENT_ATTACHMENT",
"CUSTOMS_DOCUMENT",
"CUSTOMS_RECEIPT",
"FINANCE_VOUCHER",
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
public Metadata upload(long tenantId, String businessType, String businessId, long operatorId,
MultipartFile file) {
if (tenantId < 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
}
validateBusinessType(businessType);
long parsedBusinessId = parseBusinessId(businessId);
var stored = storage.storeLoading(tenantId, parsedBusinessId, file);
FileObjectEntity value = new FileObjectEntity();
value.setTenantId(tenantId);
value.setBusinessType(businessType);
value.setBusinessId(businessId);
value.setOriginalName(stored.originalName());
value.setObjectKey(stored.objectKey());
value.setStorageProvider("LOCAL");
value.setBucketName("business-files");
value.setContentType(stored.contentType());
value.setSizeBytes(stored.size());
value.setSha256(stored.sha256());
value.setStatus("ACTIVE");
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
value.setCreatedAt(LocalDateTime.now());
value.setUpdatedAt(value.getCreatedAt());
value.setDeleted(false);
value.setVersion(0);
if (files.insert(value) != 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "附件元数据保存失败");
}
return metadata(value);
}
public Metadata metadata(long tenantId, String businessType, String businessId, long fileId) {
return metadata(required(tenantId, businessType, businessId, fileId));
}
public Download download(long tenantId, String businessType, String businessId, long fileId) {
FileObjectEntity value = required(tenantId, businessType, businessId, fileId);
return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey()));
}
private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) {
validateScope(tenantId, businessType, businessId, fileId);
FileObjectEntity value = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getBusinessType, businessType)
.eq(FileObjectEntity::getBusinessId, businessId)
.eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "业务附件不存在或归属不匹配");
}
return value;
}
private static void validateScope(long tenantId, String businessType, String businessId, long fileId) {
if (tenantId < 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
}
validateBusinessType(businessType);
parseBusinessId(businessId);
}
private static void validateBusinessType(String businessType) {
if (!BUSINESS_TYPES.contains(businessType)) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型");
}
}
private static long parseBusinessId(String businessId) {
final long value;
try {
value = Long.parseLong(businessId);
} catch (NumberFormatException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效");
}
if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效");
return value;
}
private static Metadata metadata(FileObjectEntity value) {
return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
}
public record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -101,12 +101,18 @@ public class LocalQualificationStorage {
} }
public Resource load(long tenantId, String fileName) { public Resource load(long tenantId, String fileName) {
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) throw new BizException("Invalid file name"); if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
throw fileNotFound();
}
Path directory = tenantDirectory(tenantId); Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize(); Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw fileNotFound();
try { return new UrlResource(target.toUri()); } try { return new UrlResource(target.toUri()); }
catch (Exception exception) { throw new BizException("File not found"); } catch (Exception exception) { throw fileNotFound(); }
}
private BizException fileNotFound() {
return new BizException(ResultCode.NOT_FOUND, "File not found");
} }
public void delete(StoredFile file) { public void delete(StoredFile file) {
@@ -51,6 +51,16 @@ public class MeasurementImageService {
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null); object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
} }
/** 保存尚未匹配正式入库单的墨家联调图片。 */
@Transactional
public UploadResult uploadMojiaReceiptEvidence(long tenantId, long receiptId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, receiptId, operatorId, file, "MOJIA_MEASUREMENT_RECEIPT");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId, private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) { MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file); var stored = storage.store(tenantId, inboundId, file);
@@ -124,11 +134,19 @@ public class MeasurementImageService {
@Transactional @Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) { public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId); FileObjectEntity object = requireObject(tenantId, inboundId, fileId,
if (!Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE") Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
.contains(object.getBusinessType())) { deleteObject(object, operatorId);
throw new BizException(ResultCode.CONFLICT, "仅允许清理入库测量图片"); }
}
@Transactional
public void deleteMojiaReceiptEvidence(long tenantId, long receiptId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, receiptId, fileId,
Set.of("MOJIA_MEASUREMENT_RECEIPT"));
deleteObject(object, operatorId);
}
private void deleteObject(FileObjectEntity object, long operatorId) {
object.setStatus("DELETED"); object.setStatus("DELETED");
object.setUpdatedBy(operatorId); object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
@@ -139,11 +157,17 @@ public class MeasurementImageService {
} }
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) { private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
return requireObject(tenantId, inboundId, fileId,
Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
}
private FileObjectEntity requireObject(long tenantId, long businessId, long fileId,
Set<String> businessTypes) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>() FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId) .eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId) .eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE") .in(FileObjectEntity::getBusinessType, businessTypes)
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId)) .eq(FileObjectEntity::getBusinessId, Long.toString(businessId))
.eq(FileObjectEntity::getStatus, "ACTIVE") .eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false)); .eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在"); if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
+9 -9
View File
@@ -5,7 +5,7 @@ USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object ( CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
business_type VARCHAR(32) NULL COMMENT '关联业务类型', business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS file_object (
CREATE TABLE IF NOT EXISTS file_download_token ( CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要', token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要',
expires_at DATETIME(3) NOT NULL COMMENT '过期时间', expires_at DATETIME(3) NOT NULL COMMENT '过期时间',
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS file_download_token (
CREATE TABLE IF NOT EXISTS file_download_log ( CREATE TABLE IF NOT EXISTS file_download_log (
id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID', id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID', file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID', user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID',
request_id VARCHAR(64) NULL COMMENT '请求ID', request_id VARCHAR(64) NULL COMMENT '请求ID',
@@ -62,14 +62,14 @@ CREATE TABLE IF NOT EXISTS file_download_log (
CREATE TABLE IF NOT EXISTS file_export_task ( CREATE TABLE IF NOT EXISTS file_export_task (
id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)', id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键;平台任务固定为0',
app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT', app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT',
task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号', task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号',
export_type VARCHAR(64) NOT NULL COMMENT '导出类型', export_type VARCHAR(64) NOT NULL COMMENT '导出类型',
format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式', format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式',
task_name VARCHAR(128) NOT NULL COMMENT '任务名称', task_name VARCHAR(128) NOT NULL COMMENT '任务名称',
parameters_json JSON NOT NULL COMMENT '筛选条件快照', parameters_json JSON NOT NULL COMMENT '筛选条件快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED', status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待处理、RUNNING执行中、SUCCEEDED成功、FAILED失败、EXPIRED已过期',
total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数', total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数',
processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数', processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数',
file_name VARCHAR(255) NULL COMMENT '下载文件名', file_name VARCHAR(255) NULL COMMENT '下载文件名',
@@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS file_export_task (
CREATE TABLE IF NOT EXISTS file_export_task_guard ( CREATE TABLE IF NOT EXISTS file_export_task_guard (
guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键', guard_key VARCHAR(64) NOT NULL COMMENT '任务创建串行化锁键',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '锁记录最后更新时间',
PRIMARY KEY (guard_key) PRIMARY KEY (guard_key)
) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限'; ) ENGINE=InnoDB COMMENT='导出任务创建并发保护表:低频创建入口使用单行锁保证重复提交及活动上限';
@@ -123,15 +123,15 @@ CREATE TABLE IF NOT EXISTS file_export_outbox (
tag VARCHAR(64) NOT NULL COMMENT '消息Tag', tag VARCHAR(64) NOT NULL COMMENT '消息Tag',
message_key VARCHAR(64) NOT NULL COMMENT '消息Key', message_key VARCHAR(64) NOT NULL COMMENT '消息Key',
payload_json JSON NOT NULL COMMENT '命令消息体', payload_json JSON NOT NULL COMMENT '命令消息体',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CLAIMED/SENT/QUARANTINED', status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待发送、CLAIMED已领取、SENT已发送、QUARANTINED隔离',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数', attempt_count INT NOT NULL DEFAULT 0 COMMENT '累计发送尝试次数',
next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间', next_retry_at DATETIME(3) NOT NULL COMMENT '允许发送的最早时间',
lease_owner VARCHAR(128) NULL COMMENT '领取实例标识', lease_owner VARCHAR(128) NULL COMMENT '领取实例标识',
lease_until DATETIME(3) NULL COMMENT '领取租约截止时间', lease_until DATETIME(3) NULL COMMENT '领取租约截止时间',
sent_at DATETIME(3) NULL COMMENT '发送成功时间', sent_at DATETIME(3) NULL COMMENT '发送成功时间',
last_error VARCHAR(1000) NULL COMMENT '最后发送错误', last_error VARCHAR(1000) NULL COMMENT '最后发送错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Outbox记录更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_file_export_outbox_event (event_id), UNIQUE KEY uk_file_export_outbox_event (event_id),
UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version), UNIQUE KEY uk_file_export_outbox_task_version (task_id, dispatch_version),
@@ -0,0 +1,58 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.InternalBusinessFileService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalBusinessFileController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalBusinessFileControllerTest {
@MockBean InternalBusinessFileService service;
@Resource InternalBusinessFileController controller;
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
var metadata = new InternalBusinessFileService.Metadata(81L, 0L, "CONTAINER_CLEARANCE_DOCUMENT",
"41", "clearance.pdf", "hash", "application/pdf", file.getSize());
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
assertThat(controller.upload("internal-token", 7L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", file).data())
.isEqualTo(metadata);
assertThat(controller.metadata("internal-token", 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L).data())
.isEqualTo(metadata);
}
@Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() {
when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L))
.thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2}));
var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L);
assertThat(response.getBody()).containsExactly(1, 2);
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L);
}
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
"CONTAINER_CLEARANCE_DOCUMENT", "41", file))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.metadata("internal-token", -1L,
"CONTAINER_CLEARANCE_DOCUMENT", "41", 81L))
.isInstanceOf(BizException.class).hasMessageContaining("不能为负数");
verifyNoInteractions(service);
}
}
@@ -59,4 +59,18 @@ class InternalMeasurementImageControllerTest {
assertThat(response.getBody()).containsExactly(1, 2, 3); assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(images).download(11L, 21L, 41L); verify(images).download(11L, 21L, 41L);
} }
@Test
void storesAnUnmatchedMojiaReceiptImageWithoutAnInboundId() {
var file = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "mojia.jpg",
MediaType.IMAGE_JPEG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null);
when(images.uploadMojiaReceiptEvidence(11L, 51L, 31L, file)).thenReturn(result);
var response = controller.uploadReceipt("internal-token", 31L, 11L, 51L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
verify(images).uploadMojiaReceiptEvidence(11L, 51L, 31L, file);
}
} }
@@ -82,11 +82,11 @@ class QualificationFileContractTest {
} }
@Test @Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception { void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod( var method = PlatformQualificationFileController.class.getDeclaredMethod(
"download", AuthenticatedUser.class, long.class, String.class); "download", AuthenticatedUser.class, long.class, String.class);
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value()); assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value());
} }
@Test @Test
@@ -0,0 +1,116 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class InternalBusinessFileServiceTest {
@Mock FileObjectMapper files;
@Mock BusinessFileStorage storage;
@InjectMocks InternalBusinessFileService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored(
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
});
var result = service.upload(10001L, "CUSTOMS_DOCUMENT", "41", 7L, file);
assertThat(result.fileId()).isEqualTo(71L);
assertThat(result.businessType()).isEqualTo("CUSTOMS_DOCUMENT");
assertThat(result.businessId()).isEqualTo("41");
assertThat(result.contentType()).isEqualTo("text/plain");
}
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
});
var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file);
var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file);
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41");
assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51");
assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER");
assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER");
verify(storage, times(3)).storeLoading(0L, 41L, file);
verify(storage).storeLoading(0L, 51L, file);
}
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("归属");
assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "0", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("业务ID");
assertThatThrownBy(() -> service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 0L, file))
.isInstanceOf(BizException.class).hasMessageContaining("操作人");
verifyNoInteractions(files, storage);
}
@Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
.isInstanceOf(BizException.class).hasMessageContaining("不支持");
verifyNoInteractions(files, storage);
}
@Test void downloadUsesValidatedMetadataObjectKey() {
FileObjectEntity value = new FileObjectEntity(); value.setId(71L); value.setTenantId(10001L);
value.setBusinessType("CUSTOMS_RECEIPT"); value.setBusinessId("61"); value.setOriginalName("release.txt");
value.setObjectKey("business-files/10001/61/release.txt"); value.setContentType("text/plain");
when(files.selectOne(any())).thenReturn(value);
when(storage.read(value.getObjectKey())).thenReturn("released".getBytes());
var result = service.download(10001L, "CUSTOMS_RECEIPT", "61", 71L);
assertThat(result.fileName()).isEqualTo("release.txt");
assertThat(result.content()).isEqualTo("released".getBytes());
verify(storage).read(value.getObjectKey());
}
@Test void platformMetadataAndDownloadRequireExactCompositeOwnership() {
FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L);
value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51");
value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf");
value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L);
when(files.selectOne(any())).thenReturn(value, value, null, null, null);
when(storage.read(value.getObjectKey())).thenReturn("document".getBytes());
assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName())
.isEqualTo("declaration.pdf");
assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content())
.isEqualTo("document".getBytes());
assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(storage).read(value.getObjectKey());
}
}

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