Compare commits

..
Author SHA1 Message Date
ouyangxq d7616899d8 Unify dispatch operations with matched published transfer fixture 2026-10-20 00:29:25 +08:00
ouyangxq a563f63857 feat(appointments): expose destination warehouse options 2026-10-19 17:02:25 +08:00
ouyangxq eb167347f6 test(transfer): prepare source snapshot before foreign relabel ownership check 2026-10-19 15:19:09 +08:00
ouyangxq c8608d50ba merge: retain current overseas inventory fixes with looseboard demand 2026-10-19 15:11:45 +08:00
ouyangxq dff040b509 feat(looseboard): require route contacts and pickup delivery times 2026-10-19 15:11:15 +08:00
liuyihe 26a203b4eb 提交 2026-10-19 15:00:25 +08:00
ouyangxq 21128b895f feat(trucking): refine truck demand contacts vehicle and service times 2026-10-19 14:34:33 +08:00
ouyangxq fafcf75014 fix(appointments): exclude pickup trips from delivery calendar 2026-10-19 12:21:57 +08:00
ouyangxq aa195cef66 feat(appointments): add four-type calendar sources and align release contracts 2026-10-19 12:15:43 +08:00
liuyihe fe1b2a6a46 提交 2026-10-19 11:54:29 +08:00
liuyihe e8f6bb32dc 提交 2026-10-19 10:23:09 +08:00
ouyangxq 45fbd6eff8 fix(lastleg): reconfirm received bookings after releasing all own cargo 2026-10-18 19:53:35 +08:00
ouyangxq 2c330bf9df test(lastleg): derive merchant relabel scope from profile receipt 2026-10-18 19:29:35 +08:00
ouyangxq 4bef8577fa test(lastleg): verify dispatch repair with current-run HTTP evidence 2026-10-18 19:21:12 +08:00
ouyangxq db4354bda8 fix(lastleg): align return receipt fixtures and schema totals 2026-10-18 18:45:47 +08:00
ouyangxq aa33514ec6 fix(lastleg): repair dispatch cancellation and return execution contracts 2026-10-18 16:34:14 +08:00
ouyangxq 66a0beb653 test(lastleg): mark diagnostic fixture mapper for registration contract 2026-10-18 11:49:47 +08:00
ouyangxq 5cd108b39a chore(lastleg): instrument appointment latency from restored 662dead baseline 2026-10-18 11:43:10 +08:00
ouyangxq b0acbfbb34 perf(lastleg): batch appointment requirement generation reads 2026-10-17 12:54:39 +08:00
ouyangxq 662dead053 chore(lastleg): diagnose work fact synchronization phases 2026-10-17 11:37:57 +08:00
ouyangxq 2971d6f7f0 perf(lastleg): reuse warehouse directory within sync generation 2026-10-17 09:57:54 +08:00
ouyangxq 81e4123b10 perf(lastleg): limit transport reconciliation facts and trace sync phases 2026-10-17 09:11:00 +08:00
ouyangxq 732cf99007 perf(lastleg): reuse work items within synchronization batch 2026-10-17 08:30:11 +08:00
ouyangxq 5fd74e9c8c perf(lastleg): batch receiving candidates and trace slow work invalidation 2026-10-17 07:49:56 +08:00
ouyangxq af97cd17d0 Merge production prebuild test fixes into frozen release 2026-10-16 18:44:42 +08:00
ouyangxq 5f5133bd68 test(lastleg): separate stable carton marks from return numbers 2026-10-16 18:44:32 +08:00
ouyangxq 339c70bb56 test(lastleg): isolate SQL semantic tests in bounded CI 2026-10-16 18:41:25 +08:00
ouyangxq e4bf573146 test: integrate production preflight contract correction 2026-10-16 18:14:49 +08:00
ouyangxq 4348296daf test(lastleg): validate shared appointment contract references 2026-10-16 18:14:37 +08:00
liuyihe 9ea03eb540 提交 2026-10-16 17:43:07 +08:00
liuyihe c9186c37a5 提交 2026-10-16 16:56:33 +08:00
liuyihe 6211d4ae37 提交 2026-10-16 16:53:32 +08:00
ouyangxq fee0268391 fix(appointment): link customer work to original batch requirement 2026-10-16 16:38:33 +08:00
ouyangxq 38a1f1079a Merge remote-tracking branch 'origin/test' into test 2026-10-16 16:21:51 +08:00
ouyangxq 58685945b1 fix(appointment): exclude cancelled batches without active allocations 2026-10-16 16:21:51 +08:00
liuyihe ecded9e58b 提交 2026-10-16 16:18:39 +08:00
liuyihe 488c0318ff 提交 2026-10-16 16:18:28 +08:00
ouyangxq 000125c3ec chore(sql): normalize merged initialization ending 2026-10-16 15:45:18 +08:00
ouyangxq 0708bbad2e Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/README.md
#	deploy/sql/海外区域统一增量-MySQL8.sql
2026-10-16 15:44:59 +08:00
ouyangxq 30f48bd926 feat(appointments): support scoped receiving and stable trip continuation 2026-10-16 15:43:04 +08:00
liuyihe 358feb5567 提交 2026-10-16 13:57:39 +08:00
liuyihe 5855529e97 提交 2026-10-16 11:41:24 +08:00
ouyangxq 4e2ea89403 fix(selfop): retry continuous outbound failures with bounded diagnostics 2026-10-16 11:05:37 +08:00
ouyangxq a3a5862f89 fix(delivery): use frozen relabel destination in SKU details 2026-10-15 21:48:18 +08:00
ouyangxq b8739eebf0 fix(lastleg): preserve return relabel delivery metadata and measurements 2026-10-15 21:40:37 +08:00
ouyangxq 607aaf74dc fix(lastleg): route all relabel pickups directly to outbound and verify final schemas 2026-10-15 21:05:34 +08:00
ouyangxq 4277601999 perf(firstleg): scope appointment fact source aggregation by container 2026-10-15 20:03:41 +08:00
ouyangxq ae33780dae fix(order): project exact customer fulfillment progress in detail 2026-10-15 19:46:34 +08:00
ouyangxq 097bec33b1 perf(lastleg): batch live appointment work facts and filter candidates 2026-10-15 19:10:57 +08:00
ouyangxq abf4e45e39 perf(firstleg): batch appointment source and execution facts 2026-10-15 19:08:18 +08:00
ouyangxq 2631bd3af4 fix(dispatch): use supported capacity for default draft groups 2026-10-15 18:45:05 +08:00
liuyihe cda5f3dc32 提交 2026-10-15 18:25:53 +08:00
ouyangxq caff693ad8 test(appointments): verify stable batches and current HTTP contracts 2026-10-15 18:13:52 +08:00
ouyangxq 204ae10737 fix(dispatch): persist and reuse the resolved draft group 2026-10-15 18:12:55 +08:00
ouyangxq 23ce486539 fix(firstleg): reuse source manifest within work fact batches 2026-10-15 17:52:56 +08:00
ouyangxq 994215d981 fix(appointment): keep source inspection in work transaction 2026-10-15 17:34:45 +08:00
ouyangxq 42679d4d54 fix(appointment): retain unbookable cargo as blocked work 2026-10-15 17:28:22 +08:00
ouyangxq 14b8cc625b fix(appointment): retain safe synchronization failure diagnostics 2026-10-15 17:18:09 +08:00
ouyangxq 4f9a63fb01 merge: integrate TEST updates and verify appointment release 2026-10-15 17:08:30 +08:00
ouyangxq a1d5ebdab2 feat(appointment): integrate work calendar and stable cargo batches 2026-10-15 16:55:57 +08:00
liuyihe 628b1a5732 提交 2026-10-15 16:30:37 +08:00
liuyihe c6bd6af5ce 提交 2026-10-15 15:42:10 +08:00
liuyihe 095a95e64a Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/README.md
2026-10-15 15:02:34 +08:00
ouyangxq dbb93a06c2 feat(selfop): provide frozen turnover batches for backend Excel exports 2026-10-15 15:00:03 +08:00
ouyangxq 22d79e08f9 fix(selfop): restore unique same-second inventory chains 2026-10-15 14:13:41 +08:00
ouyangxq 235b9469d9 fix(selfop): remove obsolete execution binding on config refresh 2026-10-15 13:48:49 +08:00
ouyangxq 99215bbb79 feat(selfop): reconstruct historical inventory and publish qualified turnover 2026-10-15 13:30:37 +08:00
ouyangxq d81d9cbda4 feat(selfop): add bounded inventory flow diagnostic through account egress 2026-10-15 11:37:49 +08:00
ouyangxq e19d303a76 feat(selfop): filter turnover by multiple owners across all aggregates 2026-10-15 11:12:44 +08:00
ouyangxq 72d7ed6962 feat(selfop): hide known zero inventory before turnover pagination 2026-10-15 10:29:42 +08:00
liuyihe d663d53747 Merge remote-tracking branch 'origin/test' into test 2026-10-14 20:27:06 +08:00
ouyangxq b7fb3a5054 feat(selfop): add owner inventory volume top ten chart 2026-10-14 18:17:09 +08:00
ouyangxq 7079455eb0 fix(selfop): support inventory and outbound volume sorting 2026-10-14 18:11:02 +08:00
liuyihe 471e50ffe1 提交 2026-10-14 18:01:25 +08:00
ouyangxq 8bc8518496 feat(selfop): calculate inventory and outbound volume by owner and SKU 2026-10-14 17:48:52 +08:00
ouyangxq 814eb11d2f fix(selfop): avoid loading routing manifests during idle planning 2026-10-14 17:26:41 +08:00
ouyangxq 1112d11c80 fix(lastleg): pace master data pages without holding collection leases 2026-10-14 16:56:40 +08:00
ouyangxq 63883f4ffd fix(integration): read master data source pagination fields 2026-10-14 16:43:11 +08:00
ouyangxq d311d9dc8d feat(selfop): sync versioned owner and product directories 2026-10-14 16:15:39 +08:00
ouyangxq 2b3b13913b feat(self-operated): expose and sort transit stock in turnover views 2026-10-14 14:08:52 +08:00
ouyangxq 5ace2fb584 Merge remote-tracking branch 'origin/test' into test 2026-10-14 14:01:40 +08:00
ouyangxq 6e7d922853 fix(self-operated): align turnover sales eligibility and known zero stock 2026-10-14 14:01:39 +08:00
liuyihe 1425b284dd 提交 2026-10-14 13:57:04 +08:00
liuyihe d017f5d881 提交 2026-10-14 13:42:58 +08:00
ouyangxq bc9c78122b feat(self-operated): support selected-day analytics and global quantity sorting 2026-10-14 12:44:15 +08:00
liuyihe 287edcf32b 提交 2026-10-14 12:13:26 +08:00
liuyihe 166280da5a 贴标申请,仓储转运 2026-10-14 11:37:38 +08:00
liuyihe e14bdb4f49 贴标申请,仓储转运 2026-10-14 11:36:35 +08:00
ouyangxq 83dc6dea3f 优化 2026-10-14 11:03:02 +08:00
ouyangxq 6193043880 perf(selfop): batch list checkpoints and publish each completed day 2026-10-14 10:09:16 +08:00
ouyangxq 674914c651 fix(selfop): allow explicit retry of failed continuous inventory roots 2026-10-14 09:46:49 +08:00
ouyangxq 2e2a354ba6 fix(selfop): retain turnover precision and scope reused publication runs 2026-10-14 09:37:00 +08:00
ouyangxq 6024faa9aa fix(selfop): isolate inventory paging and bound unstable scan retries 2026-10-14 09:36:52 +08:00
ouyangxq 0aa188445b fix(selfop): allow controlled continuous inventory retry in test 2026-10-14 09:29:08 +08:00
ouyangxq c0e1d42b62 perf(selfop): route collected orders once after resolution 2026-10-14 08:55:58 +08:00
ouyangxq c640fa2d0d feat(selfop): prefer list SKU quantities with targeted detail fallback 2026-10-14 08:15:16 +08:00
ouyangxq 4ae158c93e fix(selfop): preserve exact diagnostic decimals in JSON evidence 2026-10-14 05:05:23 +08:00
ouyangxq 913b9e9262 fix(selfop): persist bounded next-root quantity diagnostics 2026-10-14 04:59:05 +08:00
ouyangxq 765b6e6208 perf(lastleg): reduce inventory turnover query aggregation work 2026-10-14 03:36:54 +08:00
ouyangxq 16d0b9c80f fix: finalize inventory turnover queries and reuse snapshot aggregation 2026-10-14 01:51:51 +08:00
ouyangxq d029f9a19e 优化 2026-10-14 00:07:12 +08:00
ouyangxq 3d3507a770 feat(selfop): add bounded independent quantity diagnostic capture 2026-10-13 23:40:44 +08:00
ouyangxq 54f79b3902 feat(self-operated): expose private runtime configuration snapshots 2026-10-13 22:23:48 +08:00
ouyangxq 1d0b0918ba Merge commit 'b033a89aaf0af286cdf60cb2cdc2d51fd02deab1' into test 2026-10-13 21:51:00 +08:00
ouyangxq f1fb5662ca fix(self-operated): remove business collection deadline limits 2026-10-13 21:39:51 +08:00
ouyangxq 242a76221e feat(self-operated): persist continuous collection and frozen coverage 2026-10-13 21:13:31 +08:00
liuyihe b033a89aaf 贴标申请,仓储转运 2026-10-13 20:51:44 +08:00
ouyangxq c2bb769676 fix(selfop): remove business request count quotas 2026-10-13 20:02:07 +08:00
ouyangxq f151e74f5c fix(self-operated): rank hot SKUs for selected dates 2026-10-13 18:44:49 +08:00
liuyihe 38958c891a 贴标申请,仓储转运 2026-10-13 18:05:30 +08:00
liuyihe c07131b26f 贴标申请,仓储转运 2026-10-13 17:42:54 +08:00
ouyangxq ec7e9b36ec Merge remote-tracking branch 'origin/test' into test 2026-10-13 16:53:38 +08:00
ouyangxq 146d468ff4 feat: expose reliable collection activity and stage estimates 2026-10-13 16:50:53 +08:00
liuyihe 55764497c8 贴标申请,仓储转运 2026-10-13 16:48:54 +08:00
liuyihe 03601ab0aa 贴标申请,仓储转运 2026-10-13 15:55:31 +08:00
liuyihe c84b5f460a 贴标申请,仓储转运 2026-10-13 14:46:22 +08:00
liuyihe 89a05a2348 贴标申请,仓储转运 2026-10-13 13:46:16 +08:00
ouyangxq cebcdfb66e fix: preserve complete historical dashboard days 2026-10-13 13:43:13 +08:00
ouyangxq 496fc11b0a feat(selfop): support complete inventory sales windows 2026-10-13 12:35:39 +08:00
ouyangxq 39f914b3b5 fix(lingxing): allow bounded configurable business request pacing 2026-10-13 11:46:57 +08:00
ouyangxq 6e9466d0b4 Merge remote-tracking branch 'origin/test' into test 2026-10-13 11:19:19 +08:00
ouyangxq ed8c9b1543 test(selfop): verify every coverage bucket identity and pagination 2026-10-13 11:18:40 +08:00
liuyihe ce4eb4e8a2 Merge remote-tracking branch 'origin/test' into test 2026-10-13 11:00:37 +08:00
liuyihe 3b86a6d779 贴标申请,仓储转运 2026-10-13 11:00:31 +08:00
liuyihe c920d954ef 贴标申请,仓储转运 2026-10-13 10:47:58 +08:00
liuyihe f7a871e3e4 贴标申请,仓储转运 2026-10-13 10:47:52 +08:00
ouyangxq c13bf96cb9 feat(selfop): expose opt-in test query timing headers 2026-10-13 10:42:19 +08:00
ouyangxq 4c9878c338 feat(selfop): add opt-in test query timing diagnostics 2026-10-13 10:31:46 +08:00
ouyangxq d08ed2f2ca perf(selfop): seek latest outbound date for inventory page 2026-10-13 10:11:06 +08:00
ouyangxq 2d7b6e4381 test(selfop): verify warehouse summary build transactions 2026-10-13 10:04:06 +08:00
ouyangxq 25db631ff7 perf(selfop): build versioned warehouse daily sales summaries 2026-10-13 09:40:55 +08:00
ouyangxq ca4e7086e9 perf(selfop): skip unavailable dashboard aggregates 2026-10-13 03:45:01 +08:00
ouyangxq 7ae91eb276 fix(lastleg): persist bounded test collection session budgets 2026-10-13 03:08:36 +08:00
ouyangxq 1fdd978084 perf(selfop): paginate sales summary before day coverage enrichment 2026-10-13 02:46:20 +08:00
ouyangxq 341b7635c7 perf(selfop): materialize sales bounds without redundant deduplication 2026-10-13 02:31:46 +08:00
ouyangxq afd3bbaa3d perf(selfop): page stock keys before sales enrichment 2026-10-13 02:13:56 +08:00
ouyangxq 2c8c26490a perf(selfop): stabilize profile candidate join order 2026-10-13 02:00:14 +08:00
ouyangxq bf693fab6b perf(selfop): index staged observation joins and verify candidate builds 2026-10-13 01:47:17 +08:00
ouyangxq d689112656 perf(selfop): share dashboard aggregates and cover daily trend reads 2026-10-13 01:29:14 +08:00
ouyangxq 99d206eb00 perf(selfop): cover range reads and stream yearly SKU aggregation 2026-10-13 01:05:52 +08:00
ouyangxq a41c271a67 feat(selfop): add warehouse dashboard and exact inventory drilldowns 2026-10-13 00:45:39 +08:00
ouyangxq a84a9b730e fix(selfop): publish proven manual sales windows for configured warehouses 2026-10-13 00:30:48 +08:00
ouyangxq ae1a809357 fix(selfop): publish complete single-pass inventory observations 2026-10-13 00:10:12 +08:00
ouyangxq 51c0d77fd4 fix(selfop): stabilize inventory and order quantity fingerprints 2026-10-12 23:50:39 +08:00
ouyangxq 86e05a77e6 fix(selfop): allow controlled manual collection in test 2026-10-12 23:21:57 +08:00
ouyangxq 6e9f4b7932 perf(selfop): aggregate daily facts before SKU coverage calculations 2026-10-12 23:06:45 +08:00
ouyangxq cbc316b34d fix(selfop): expose scoped validation tasks without published scopes 2026-10-12 22:57:18 +08:00
ouyangxq b71380de31 fix(selfop): discover warehouses with bounded validation and optimize queries 2026-10-12 22:22:08 +08:00
ouyangxq e42d358e71 Merge remote-tracking branch 'origin/test' into test 2026-10-12 21:25:28 +08:00
ouyangxq 06de9b1c93 test(selfop): verify publication transactions against opt-in TEST MySQL 2026-10-12 21:24:56 +08:00
liuyihe fb98b1a8ee 贴标申请,仓储转运 2026-10-12 21:18:29 +08:00
ouyangxq 8526697b08 fix: quote inline descriptions in Lingxing OpenAPI contract 2026-10-12 21:10:18 +08:00
ouyangxq b702ed9f45 test: verify self-operated analytics through TEST Gateway and synthetic query fixtures 2026-10-12 20:59:54 +08:00
ouyangxq d4dc4f875a fix: register current synchronous service dependencies and guard callback boundaries 2026-10-12 20:58:16 +08:00
ouyangxq d782b2cd24 fix: reject unavailable self-operated collection commands before initialization 2026-10-12 20:45:31 +08:00
ouyangxq 3b137f8946 Merge remote-tracking branch 'origin/test' into test 2026-10-12 20:27:50 +08:00
ouyangxq a1f1d93902 feat: add self-operated analytics and enforce Lastleg module boundaries 2026-10-12 20:27:35 +08:00
liuyihe 0eb58f9dd1 贴标申请,仓储转运 2026-10-12 18:19:08 +08:00
liuyihe 9b333df720 贴标申请,仓储转运 2026-10-12 18:11:23 +08:00
liuyihe 46507e41c6 贴标申请,仓储转运 2026-10-12 18:01:16 +08:00
ouyangxq c06e788b0f fix: complete single-order acceptance and add Lingxing WMS integration 2026-10-12 16:09:21 +08:00
liuyihe 6c4ee2bfde 贴标申请,仓储转运 2026-10-12 15:57:47 +08:00
liuyihe a1cd47dabd 贴标申请,仓储转运 2026-10-12 13:42:23 +08:00
liuyihe ea3aae6093 Merge remote-tracking branch 'origin/test' into test 2026-10-12 13:42:13 +08:00
ouyangxq c545a77190 test(firstleg): keep sailing copy dates in the future 2026-10-12 11:48:49 +08:00
ouyangxq 3cb4a5676a fix(firstleg): use a single cabinet business number 2026-10-12 11:44:58 +08:00
liuyihe b4b00ae3dd 贴标申请,仓储转运 2026-10-12 11:18:58 +08:00
ouyangxq 29b562ebf5 fix: require dedicated appointment cancellation command 2026-10-11 14:14:51 +08:00
ouyangxq 43494cbfa3 fix: retain appointment cancellation reasons and query history 2026-10-11 13:26:00 +08:00
ouyangxq d4b72594ab test(procurement): reject receipt corrections after shipment 2026-10-10 19:52:36 +08:00
ouyangxq 15ab355dc2 fix(appointments): include destination timezone in batch preview 2026-10-10 18:37:38 +08:00
liuyihe c6471d06ff 贴标申请,仓储转运 2026-10-10 16:58:33 +08:00
liuyihe 05302490fb 贴标申请,仓储转运 2026-10-10 16:15:17 +08:00
ouyangxq 8e36bd90ef Merge remote-tracking branch 'origin/test' into test 2026-10-10 15:34:19 +08:00
ouyangxq 0ac2b849c3 feat(appointments): track forecast impacts and handling history 2026-10-10 15:33:13 +08:00
liuyihe 84864c2ecf Merge remote-tracking branch 'origin/test' into test 2026-10-10 14:47:42 +08:00
liuyihe d1b4cc9c0f 贴标申请,仓储转运 2026-10-10 14:47:31 +08:00
ouyangxq 331f750871 fix(procurement): reject unsupported purchase payload fields 2026-10-10 14:44:23 +08:00
ouyangxq 25aae44918 Merge remote-tracking branch 'origin/test' into test 2026-10-10 14:19:42 +08:00
ouyangxq 4b5b639adc feat(appointments): expose authoritative arrival forecast observations 2026-10-10 14:10:17 +08:00
ouyangxq 71111bf21b feat(appointments): discover external booking candidates through fleet boundary 2026-10-10 14:03:42 +08:00
ouyangxq 369613a2e7 test(procurement): verify pending commands and late successful receipts 2026-10-10 14:01:50 +08:00
liuyihe 12a5093b41 贴标申请,仓储转运 2026-10-10 13:48:25 +08:00
ouyangxq 721d92ac7b fix: include overlapping overnight FBA appointments in calendar 2026-10-10 12:40:35 +08:00
ouyangxq 6e1d90aafe test: cover fleet calendar authorization in integrated contracts 2026-10-10 12:33:39 +08:00
ouyangxq 3a252dfc70 feat(fleet): expose bounded origin-aware trip calendar 2026-10-10 12:15:02 +08:00
ouyangxq 83049ed9e5 feat(warehouse): provide bounded internal historical metadata batch 2026-10-10 11:52:29 +08:00
ouyangxq 46ae8ab1c6 feat(appointments): expose bounded terminal pickup calendar 2026-10-10 11:43:47 +08:00
ouyangxq 47dc9e8bf6 fix(warehouse): use location time zones for appointment scheduling 2026-10-10 11:02:49 +08:00
ouyangxq c4c1bddeec feat(appointments): unify pre-inbound planning and confirmed FBA bookings 2026-10-10 10:38:06 +08:00
ouyangxq 24f5ccf7ff feat(appointment): share pickup and receiving followups by container 2026-10-10 08:42:01 +08:00
ouyangxq 885cac13a4 test(fleet): exercise simplified intake HTTP workflow 2026-10-10 08:07:27 +08:00
ouyangxq 2c970f252d fix(lastleg): snapshot customer name for incoming cargo 2026-10-10 08:05:23 +08:00
ouyangxq 373f1b2c18 test(procurement): verify fee source recovery without duplicate debit 2026-10-10 03:43:53 +08:00
ouyangxq c0fbe2698b fix(procurement): read MySQL fulfillment timestamps with JDBC types 2026-10-10 02:42:59 +08:00
ouyangxq a973be2103 feat(procurement): implement sample fulfillment and settled fee workflow 2026-10-10 02:19:38 +08:00
ouyangxq 040f3bbd93 fix(lastleg): preserve followup timestamp on replay 2026-10-09 22:31:51 +08:00
ouyangxq 0d75e311d0 feat(lastleg): connect appointment followups and fleet intake 2026-10-09 22:04:49 +08:00
ouyangxq 1202cafadf feat(firstleg): count actionable customer customs cabinets 2026-10-09 18:14:17 +08:00
ouyangxq d11abb78ac Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-09 17:36:25 +08:00
ouyangxq 261cdc87cb 优化 2026-10-09 17:36:24 +08:00
liuyihe 7e37770f26 贴标申请,仓储转运 2026-10-09 17:27:02 +08:00
ouyangxq 74bf0e73c5 test: prepare isolated gray domestic PDA H5 tasks without auto-picking 2026-10-09 17:21:56 +08:00
ouyangxq 023b28e31f fix(lastleg): normalize source key comparisons across MySQL collations 2026-10-09 16:31:19 +08:00
ouyangxq f6605a1ab0 fix(sql): keep return counting in clean initialization definitions 2026-10-09 16:20:50 +08:00
ouyangxq c1aaaf207a Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
2026-10-09 16:13:28 +08:00
ouyangxq 3b5454fb99 feat(lastleg): query commercial sources independently of fulfillment types 2026-10-09 16:12:02 +08:00
liuyihe bac7e0b214 贴标申请,仓储转运 2026-10-09 15:53:30 +08:00
liuyihe 1e15a2838a 贴标申请,仓储转运 2026-10-09 15:25:00 +08:00
liuyihe b4b3ae452c 贴标申请,仓储转运 2026-10-09 15:20:15 +08:00
ouyangxq c4a7c2a22d feat(fleet): match cargo stops when binding dispatches 2026-10-09 15:04:51 +08:00
liuyihe 2ec5414b39 贴标申请,仓储转运 2026-10-09 11:49:17 +08:00
ouyangxq 7fd7699f6e test: verify customer isolation on shared appointment items 2026-10-09 11:10:34 +08:00
ouyangxq fddae546a0 fix: avoid existing test permission IDs for appointment menu 2026-10-09 11:05:24 +08:00
ouyangxq e94271706c feat: support shared FBA appointments and arrival calendar 2026-10-09 11:01:39 +08:00
ouyangxq 4f9433c39a test(pda): bind receiving fixtures to exact source container booking 2026-10-08 21:36:53 +08:00
ouyangxq 6b6d217774 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-10-08 20:16:30 +08:00
ouyangxq ed99ecba1c 优化 2026-10-08 19:09:30 +08:00
liuyihe 315a510969 贴标申请,仓储转运 2026-10-08 18:27:52 +08:00
ouyangxq 2c0f55d9b7 fix(config): restore required IAM Nacos imports 2026-10-08 18:18:33 +08:00
liuyihe a24a223a35 贴标申请,仓储转运 2026-10-08 18:10:06 +08:00
ouyangxq 3a9ee57e53 fix(lastleg): restore fulfillment safeguards and reconcile return regressions 2026-10-08 18:08:42 +08:00
ouyangxq c1ddb658bd 优化 2026-10-08 17:07:57 +08:00
liuyihe 16d2d9111a 贴标申请,仓储转运 2026-10-08 15:55:47 +08:00
ouyangxq e09f8785e1 fix(lastleg): export all pages when pagination caps batch size 2026-10-08 15:31:56 +08:00
ouyangxq d89198ee21 fix(lastleg): classify all first-leg orders by sailing stage 2026-10-08 15:23:34 +08:00
ouyangxq 27a89a6ff3 fix(lastleg): filter domestic first-leg orders by sailing stage 2026-10-08 15:12:47 +08:00
ouyangxq 136a2bc3b5 fix(lastleg): expose dispatch reviews and filter available capacity offers 2026-10-08 14:11:55 +08:00
ouyangxq 1cb1f2d0c0 fix(schema): allow warehouse transfer inbound plan source 2026-10-08 08:45:56 +08:00
ouyangxq c216f393a7 fix(fleet): preserve unknown commercial facts for pooled freight 2026-10-07 19:38:56 +08:00
ouyangxq cfdedd8861 test(fleet): close pool cancellation capacity and isolation matrices 2026-10-07 18:11:55 +08:00
ouyangxq dbdd7bb066 test(fleet): isolate pick commands per customer dispatch 2026-10-07 17:21:58 +08:00
ouyangxq 27c0fe80f6 fix(fleet): align customer identity collation for fulfillment joins 2026-10-07 17:16:50 +08:00
ouyangxq 5c6cd6af44 test(fleet): use local wall time for planned delivery fixture 2026-10-07 16:54:05 +08:00
ouyangxq 001219460f feat(fleet): add internal capacity pool and warehouse fulfillment bridge 2026-10-07 16:46:56 +08:00
ouyangxq 564cfa107c fix(audit): expose real actuator health status 2026-10-07 11:48:05 +08:00
ouyangxq 7ff6512864 test(deploy): verify unified TEST and gray environment contracts 2026-10-07 11:17:28 +08:00
ouyangxq ec0c06ff88 fix(deploy): publish unified services to temporary gray environment 2026-10-07 11:02:47 +08:00
ouyangxq 6346e7bd95 fix(lastleg): use reassigned FBA destination in inbound plans 2026-10-06 10:07:23 +08:00
ouyangxq bc8c1467ee fix(lastleg): return persisted grouped dispatch replay facts 2026-10-06 00:52:48 +08:00
ouyangxq 60543adf63 fix(lastleg): prevent transfer business number collisions 2026-10-06 00:20:43 +08:00
ouyangxq 4bde1a8352 fix(firstleg): avoid shared lock upgrades when initializing customs 2026-10-05 17:03:01 +08:00
ouyangxq 261c720c77 fix(firstleg): read concurrent customs initialization with current row lock 2026-10-05 16:35:22 +08:00
ouyangxq 090f57bfb1 fix(firstleg): stop retrying deterministic completion preflight conflicts 2026-10-05 16:05:06 +08:00
ouyangxq adaac7aca4 fix(finance): include billed sources in trip financial attribution 2026-10-05 12:35:38 +08:00
ouyangxq f13d340620 fix(finance): retain trip receipts after partial and full payment 2026-10-05 12:22:18 +08:00
ouyangxq 8ac2400043 docs(discard): align confirmation semantics and current coordination checks 2026-10-05 12:07:02 +08:00
ouyangxq 0c26e1e99d test(discard): expect approval to reserve stock until PDA confirmation 2026-10-05 12:00:09 +08:00
ouyangxq 7577650eee feat(fleet): read attributable trip receivables and receipts from finance 2026-10-05 11:51:47 +08:00
ouyangxq 113d2b1fec fix(discard): recover completed PDA confirmation without duplicate writes 2026-10-05 08:10:43 +08:00
ouyangxq 5864e62443 test(lastleg): include transfer reservations in hold concurrency fixture 2026-10-05 07:38:43 +08:00
ouyangxq 5b4d9624a9 fix(inventory): enforce warehouse transfer reservation ownership 2026-10-05 07:24:53 +08:00
ouyangxq 548a133de6 fix(transfer): preserve inbound ownership and expose durable receipt recovery 2026-10-05 06:34:23 +08:00
ouyangxq 47acfc991b fix(lastleg): keep unconfirmed container resources recoverable 2026-10-05 05:26:01 +08:00
ouyangxq 3afb3bcfd1 fix(test): stream resource fault configuration through stdin 2026-10-05 04:52:38 +08:00
ouyangxq 5ea1c277bc fix(test): retain safe inbound resource fault diagnostics 2026-10-05 04:11:33 +08:00
ouyangxq 6fc32574c7 fix(lastleg): exclude cancelled dispatches from inbound status 2026-10-05 04:09:15 +08:00
ouyangxq d7981f7575 test(inbound): add recoverable scoped resource fault runtime 2026-10-05 03:48:29 +08:00
ouyangxq cc1fa74c63 test(dispatch): register grouped draft in browser API boundary 2026-10-05 03:45:05 +08:00
ouyangxq c194dd2862 fix(transfer): reserve receipt resources with durable recovery intents 2026-10-05 03:37:13 +08:00
ouyangxq 3f8ac23aba feat(transfer): persist receipt resource intents in schema baseline 2026-10-05 03:13:31 +08:00
ouyangxq 8e46ba6985 fix(dispatch): preserve loading groups in atomic draft pre-arrangement 2026-10-05 03:12:44 +08:00
ouyangxq e1cb01e6fb test(transfer): preserve rejected bind evidence and enforce completed unit facts 2026-10-05 02:50:29 +08:00
ouyangxq b371fab53d fix(transfer): settle linked relabel fees after final receipt 2026-10-05 02:47:22 +08:00
ouyangxq 9e4a838ba1 test(inbound): add scoped one-shot resource confirmation fault proxy 2026-10-05 02:45:27 +08:00
ouyangxq 49ac9222b5 fix(shipping): replay immutable carrier facts without updates 2026-10-05 02:32:06 +08:00
ouyangxq a0a4e03da9 fix(inbound): expose original command for resource confirmation recovery 2026-10-05 02:13:22 +08:00
ouyangxq b000b056d1 feat(orders): expose customer-owned loaded transport facts 2026-10-05 01:06:11 +08:00
ouyangxq 5ad3fec449 test(firstleg): preserve partial and skipped loading lifecycle regression 2026-10-05 00:44:28 +08:00
ouyangxq 67c528c3e2 test(fba): add scoped transfer lifecycle and remaining-node evidence helpers 2026-10-05 00:37:18 +08:00
ouyangxq 4bded73040 fix(iam): register stock transfer execution permission 2026-10-04 22:11:31 +08:00
ouyangxq ea9732749a fix(firstleg): finish only actual picked cargo owners 2026-10-04 20:45:01 +08:00
ouyangxq 1302275b71 docs(lastleg): group transfer operations with platform contracts 2026-10-04 20:29:30 +08:00
ouyangxq 76f02d8d45 fix(lastleg): complete stock transfer through linked relabel 2026-10-04 20:21:16 +08:00
ouyangxq 7319fba35a fix(firstleg): settle and validate actual partial loading cargo 2026-10-04 19:01:40 +08:00
ouyangxq 22133fe604 fix(firstleg): project completed loading lists from actual picked cargo 2026-10-04 18:04:21 +08:00
ouyangxq cbd5dc0ca2 perf(test): normalize report secrets once per redaction 2026-10-04 15:00:24 +08:00
ouyangxq 055cd29e29 fix(test): resolve loading customer credentials per run 2026-10-04 14:40:40 +08:00
ouyangxq 791d1a650f test(fleet): verify same-route costs from three real pallet sources 2026-10-04 12:07:37 +08:00
ouyangxq dc993c9713 feat(lastleg): retain receipt and redispatch history on exceptions 2026-10-04 10:45:05 +08:00
ouyangxq 446fad33d0 fix(test): require precise database lock conflict during rollback 2026-10-04 10:44:33 +08:00
ouyangxq ba4179b28c fix(test): wait for database timeout before judging fleet rollback 2026-10-04 10:37:43 +08:00
ouyangxq 8a735020e3 test(fleet): verify two-customer departure rollback in TEST 2026-10-04 10:32:26 +08:00
ouyangxq c46a68e32b fix(delivery): project current dispatched cargo instead of whole order SKU quantities 2026-10-04 02:54:52 +08:00
ouyangxq 9cd709edee test(lastleg): verify MySQL hold and full PDA picking races 2026-10-04 01:58:02 +08:00
ouyangxq e8cf5e70ba test(order): verify warehouse faults and await recovery projection 2026-10-04 00:04:36 +08:00
ouyangxq 3f6083ff02 fix(acceptance): keep safe fleet metadata clear of sensitive report fields 2026-10-03 23:58:47 +08:00
ouyangxq 19e9913f30 fix(acceptance): target deployed TEST Firstleg container for read-only audits 2026-10-03 23:39:06 +08:00
ouyangxq 1f7ddf7bc9 fix(delivery): expose signed quantity and publish correct signature instant 2026-10-03 23:09:24 +08:00
ouyangxq 0c8ede3889 feat(test): continue reviewed return source sample with pre-signature audit 2026-10-03 22:13:15 +08:00
ouyangxq 6dbc899626 fix(lastleg): resolve mixed-pallet return sources by dispatch snapshots 2026-10-03 21:54:07 +08:00
ouyangxq 48faf43e37 fix(test): bind fleet statistics evidence without optional remarks 2026-10-03 21:26:46 +08:00
ouyangxq 3987562890 feat(test): support scoped Firstleg read-only receiver evidence 2026-10-03 21:24:00 +08:00
ouyangxq 18b39b4378 fix(fleet): reuse reviewed handover vehicle and check formal responses 2026-10-03 21:00:07 +08:00
ouyangxq 99a469e6cf fix(fleet): retain unavailable source cost shares for multi-stop trips 2026-10-03 20:36:09 +08:00
ouyangxq f2e25ec34b fix(fleet): verify dispatch cargo and retain checked costs on continuation 2026-10-03 20:24:56 +08:00
ouyangxq f56c68d952 test(fleet): isolate recovery actor phone generation 2026-10-03 20:13:50 +08:00
ouyangxq 6d922516f6 test(fleet): resume reviewed post-departure acceptance safely 2026-10-03 20:06:02 +08:00
ouyangxq bab967acce test(fleet): preserve unavailable allocations for multi-stop trips 2026-10-03 19:51:29 +08:00
ouyangxq e5af1ba758 test(fleet): handle PDFs from direct print requests 2026-10-03 19:27:03 +08:00
ouyangxq dbc7688516 test(fleet): preserve PDF and direct fetch prerequisite evidence 2026-10-03 19:19:16 +08:00
ouyangxq c516347565 test(fleet): verify booking quote source linkage in TEST 2026-10-03 19:09:22 +08:00
ouyangxq 3bf2612470 test(fleet): journal prerequisite writes and stop on unknown results 2026-10-03 19:07:58 +08:00
ouyangxq 499354ffd2 test(firstleg): align initialization table count with current baseline 2026-10-03 18:43:25 +08:00
ouyangxq 4ff55ba3e0 fix(ci): bound Maven resources and serialize shared builder jobs 2026-10-03 17:52:47 +08:00
ouyangxq 78a4d4a089 test(order): verify rejected XLSX imports preserve scoped database facts 2026-10-03 17:12:49 +08:00
ouyangxq 14696fb1a1 test(order): verify foreign customer addresses rejected in XLSX imports 2026-10-03 17:08:15 +08:00
ouyangxq e49ad96cc4 feat(fleet): attach scoped evidence to measured trip records 2026-10-03 16:38:37 +08:00
ouyangxq d05ff1cd26 test(fleet): cover partial returns and redispatch through real API flow 2026-10-03 16:11:52 +08:00
ouyangxq 8700fc3423 feat(lastleg): redispatch returned cargo and reconcile source signatures 2026-10-03 16:06:54 +08:00
ouyangxq e740047e03 test(order): prove foreign customer address rejection through real API probes 2026-10-03 16:03:34 +08:00
ouyangxq 8f356b1337 test(order): compare import templates across independent test customers 2026-10-03 15:57:36 +08:00
ouyangxq 18935f1379 test(order): verify live import template directories without business writes 2026-10-03 15:53:55 +08:00
ouyangxq bf8a985f22 test(order): add readonly merchant workbench pagination acceptance 2026-10-03 15:46:42 +08:00
ouyangxq dd52bfd043 test(fleet): verify operations from immutable current-run evidence 2026-10-03 15:00:12 +08:00
ouyangxq 780d37e1d2 fix(order): paginate merchant workbench with scoped queue counts 2026-10-03 14:57:51 +08:00
ouyangxq 7d9ee18196 test(fleet): persist independent acceptance requests and current-trip evidence 2026-10-03 14:52:04 +08:00
ouyangxq a6fd0ba3c5 feat(fleet): bind quote versions and reconcile actual return quantities 2026-10-03 13:32:55 +08:00
ouyangxq cb6c54ffe5 feat(fleet): integrate costs quotes operations and in-transit handovers 2026-10-03 12:38:04 +08:00
ouyangxq 274ecac474 fix(scripts): validate current vehicle task registry 2026-10-03 12:15:12 +08:00
ouyangxq eadb431f33 fix(lastleg): read committed order command replays after lock waits 2026-10-03 12:13:34 +08:00
ouyangxq 34cac9c42a test(fleet): verify driver qualification lifecycle 2026-10-03 11:53:58 +08:00
ouyangxq 65c12eaf5e fix: expose the selected container schedule for editing 2026-10-03 11:09:52 +08:00
ouyangxq ba7dcd39f2 test(fleet): verify vehicle care lifecycle independently 2026-10-03 10:54:03 +08:00
ouyangxq d4d5d79070 fix(fleet): compare customer identities without collation conflicts 2026-10-03 10:18:48 +08:00
ouyangxq 30559fd4eb feat(fleet): query vehicle delivery and reassignment history 2026-10-03 10:10:55 +08:00
ouyangxq d93185e750 fix: schedule manual outbox retries in UTC 2026-10-03 09:27:46 +08:00
ouyangxq 588227f181 test: match non-null fee source hash serialization 2026-10-03 09:10:34 +08:00
ouyangxq 4968745764 test: verify fee void ACK loss with original event recovery 2026-10-03 09:02:25 +08:00
ouyangxq 912e69aeb2 test: isolate fee void ACK loss and preserve wallet delivery 2026-10-03 08:54:52 +08:00
ouyangxq 933dd4cdef test(fba): retain parent proof across isolated fragment contexts 2026-10-03 08:20:27 +08:00
ouyangxq 51bf4e5558 test(fba): verify signed delivery ACK loss and original event recovery 2026-10-03 08:13:04 +08:00
ouyangxq ef1eef3278 test(fleet): close shared trips and verify resource release 2026-10-03 07:51:17 +08:00
ouyangxq 2992871975 test(fleet): resolve raw position ownership through its balance 2026-10-03 07:44:17 +08:00
ouyangxq 18d22418ca test(fleet): cover separate customers sharing one physical trip 2026-10-03 07:42:18 +08:00
ouyangxq 4e129a7f4a test(fleet): read complete customer inventory snapshots 2026-10-03 07:25:37 +08:00
ouyangxq d858357c5d test(fba): preserve verified fleet and fulfillment acceptance workflows 2026-10-03 06:26:33 +08:00
ouyangxq c55bd7f4b6 fix(test): align order dependency fault preflight with Nacos v3 2026-10-03 06:21:57 +08:00
ouyangxq bde1cd532e test(order): add isolated dependency failure and recovery verification 2026-10-03 05:25:30 +08:00
ouyangxq 5e683da3b9 feat(fleet): enforce reviewed trips and original fee coverage for dispatch 2026-10-03 04:18:52 +08:00
ouyangxq c50e9f97d1 fix(tooling): validate explicitly registered TEST task ownership 2026-10-03 03:21:14 +08:00
ouyangxq dc05eb3f8a test(lastleg): verify hold cycles against immutable receipt stock 2026-10-03 02:12:42 +08:00
ouyangxq 375e355faf fix(iam): define and grant existing cargo discard approval permission 2026-10-03 01:57:11 +08:00
ouyangxq 7530a95fbd fix(lastleg): scope inventory holds by cycle and align pick lock ordering 2026-10-03 01:34:14 +08:00
ouyangxq 1db6e744c8 test(order): distinguish publication and final price book states 2026-10-03 00:50:52 +08:00
ouyangxq 0319bb51a0 test(order): verify channel removal using customer-owned price books 2026-10-03 00:46:00 +08:00
ouyangxq 4cd51a4955 fix(firstleg): compare stable carrier facts separately from response audit hashes 2026-10-03 00:39:57 +08:00
ouyangxq 9803dcfd2c test(order): verify qualification and isolated negative balance gates 2026-10-03 00:27:45 +08:00
ouyangxq c0c6e21833 style(tests): normalize platform command fixture line endings 2026-10-02 23:37:38 +08:00
ouyangxq d1a13cdc80 test(order): verify platform submit and cancel command boundaries 2026-10-02 23:37:15 +08:00
ouyangxq c55ecabbfb fix(order): enforce versioned platform commands and submissions 2026-10-02 23:14:24 +08:00
ouyangxq 25580dab86 test(order): assert multipart rejection for oversized imports 2026-10-02 21:53:29 +08:00
ouyangxq afb02a0a1b test(order): compare Excel error row as numeric cell 2026-10-02 21:49:48 +08:00
ouyangxq 8c1905418e test(order): accept omitted identifiers on failed import rows 2026-10-02 21:46:39 +08:00
ouyangxq 58dfcc21f6 fix(order): make merchant draft edits and cancellations versioned and idempotent 2026-10-02 21:33:39 +08:00
ouyangxq 4b531c7e22 test: integrate reliable event recovery and receiving evidence 2026-10-02 20:14:17 +08:00
ouyangxq d0df738bba fix(firstleg): keep loading evidence replacement transactional 2026-10-02 19:50:47 +08:00
ouyangxq c9a1e93299 test(firstleg): retry lost evidence uploads with the same command 2026-10-02 19:32:57 +08:00
ouyangxq fa51d9235c test(lastleg): verify return outbound stock and replay invariants 2026-10-02 18:21:55 +08:00
ouyangxq 4655090f76 fix(lastleg): account return carton outbound stock atomically 2026-10-02 17:47:11 +08:00
ouyangxq e15bec3052 fix(lastleg): expose and validate individual return carton picks 2026-10-02 16:59:16 +08:00
ouyangxq 195446e532 fix(order): preserve tax mode in remainder child orders 2026-10-02 15:03:09 +08:00
ouyangxq f532e5ee7e fix(orders): filter tax mode before unified order pagination 2026-10-02 14:44:51 +08:00
ouyangxq 2d73ce9f37 fix(firstleg): preserve confirmed loading history in departure gates 2026-10-02 13:36:39 +08:00
ouyangxq 0b34f99be5 fix(firstleg): validate public port schedule queries with zero scope 2026-10-02 11:16:15 +08:00
ouyangxq 8b9b135ab5 fix(lastleg): align dispatch candidates with relabel reservation gate 2026-10-02 10:34:00 +08:00
ouyangxq a291bd6d45 fix(firstleg): permit platform public port schedule scope 2026-09-30 18:18:57 +08:00
ouyangxq 8a957790fc test(firstleg): cover public schedule dispatch fallback 2026-09-30 17:58:36 +08:00
ouyangxq 8d9d38ed4c fix(firstleg): allow public sailing schedule dispatch fallback 2026-09-30 17:53:12 +08:00
ouyangxq 36a74eb58e fix(firstleg): support platform scope port schedules 2026-09-30 17:41:16 +08:00
ouyangxq 32fb9ec7b3 优化 2026-09-30 17:13:11 +08:00
ouyangxq babc7de4b6 fix(order): require dropship SKU before submission 2026-09-30 16:51:49 +08:00
ouyangxq 94881d23fd test(firstleg): verify ship exception persistence and rollback 2026-09-30 16:35:05 +08:00
ouyangxq b436f3cf1f fix(lastleg): retain location code in immutable stock receipts 2026-09-30 16:35:03 +08:00
ouyangxq 1a7e4800f5 fix(firstleg): derive remainder pallet labels from frozen allocation facts 2026-09-30 15:28:50 +08:00
ouyangxq 71202bce70 fix(order): remove manual completion bypass of fulfillment gate 2026-09-30 15:26:35 +08:00
ouyangxq 22584e7fde 优化 2026-09-30 15:16:39 +08:00
ouyangxq 0919a61661 优化 2026-09-30 14:27:45 +08:00
ouyangxq 39af04f169 优化 2026-09-30 14:01:52 +08:00
ouyangxq 041a27c325 test(fulfillment): reconcile frozen PDA sources and dispatch departure gate 2026-09-30 13:50:13 +08:00
ouyangxq 13028d8a5a fix(order): complete root fulfillment only after effective remainders 2026-09-30 13:49:40 +08:00
ouyangxq bd73c7a326 fix(lastleg): expose authoritative outbound type to PDA 2026-09-30 13:33:53 +08:00
ouyangxq 7f3c0690cd test(shipping): validate platform customer scope separately from business IDs 2026-09-30 13:30:27 +08:00
ouyangxq 74f0d823da test(fulfillment): reconcile exact source positions and inventory balances 2026-09-30 13:15:20 +08:00
ouyangxq eae630e60d fix(shipping): allow platform scope tracking resources 2026-09-30 13:03:33 +08:00
ouyangxq d320e78bbc fix(inventory): resolve returns from immutable shipment SKU facts 2026-09-30 13:00:40 +08:00
ouyangxq 80632ba409 test: strengthen customs and fulfillment acceptance boundaries 2026-09-30 12:58:15 +08:00
ouyangxq 125ca34ba4 fix(outbound): atomically confirm platform mixed-container dispatch and tracking 2026-09-30 12:37:00 +08:00
ouyangxq 5cb2a5145f fix(inventory): normalize FBA return SKU and match durable shipment command identity 2026-09-30 12:32:59 +08:00
ouyangxq 2f0b202590 fix(test): assign authenticated PDA operator in pallet matrix 2026-09-30 12:22:47 +08:00
ouyangxq 4a14001a09 fix(lastleg): restore dispatch returns from immutable pick source and expose customer receipts 2026-09-30 12:09:19 +08:00
ouyangxq c7f880300c fix(customs): keep declaration pending while exceptions remain 2026-09-30 11:48:14 +08:00
ouyangxq 6731be1a6b fix(auth): avoid gap locks when creating accounts 2026-09-30 11:29:02 +08:00
ouyangxq c7945ad8fd fix(api): preserve null data in response envelopes 2026-09-30 11:18:40 +08:00
ouyangxq e5832d5c67 fix(firstleg): refresh inbound picking progress after task completion 2026-09-30 11:03:47 +08:00
ouyangxq f87caf9dd1 fix(lastleg): retry shipping nodes until loading handover exists 2026-09-30 00:23:06 +08:00
ouyangxq 221227204d docs: record deployed test stack and remaining acceptance gates 2026-09-29 23:54:02 +08:00
ouyangxq 88c7fcaca9 优化 2026-09-29 20:17:36 +08:00
ouyangxq 6c0a026cc4 完成单机TEST统一入口与可靠投递回归收尾 2026-09-29 19:48:34 +08:00
ouyangxq 64a70c9f28 优化 2026-09-29 18:48:58 +08:00
ouyangxq 7bc4c52ba4 优化 2026-09-29 18:48:57 +08:00
ouyangxq a530205d2b 优化 2026-09-29 18:47:49 +08:00
ouyangxq c2927ab9a6 优化 2026-09-29 18:47:37 +08:00
ouyangxq 2c60a29e0f 优化 2026-09-29 18:06:18 +08:00
ouyangxq 54ad26c607 优化 2026-09-29 17:55:54 +08:00
ouyangxq 41d8387cd5 test: align acceptance contracts with current initialization and service clients 2026-09-29 15:06:42 +08:00
ouyangxq 60f61d7e0a test: verify virtual receiving location rejects pallet restore 2026-09-29 15:02:15 +08:00
ouyangxq cb8e5fb145 fix(overseas): preserve shared overflow locations during pallet moves 2026-09-29 14:39:11 +08:00
ouyangxq 098cc785d8 Merge remote-tracking branch 'origin/test' into hotfix 2026-09-29 14:18:20 +08:00
ouyangxq 8df0f4e823 fix(overseas): repair pallet transitions and transfer queries 2026-09-29 14:18:20 +08:00
ouyangxq 875d2497f4 docs(firstleg): document price book channel query 2026-09-29 04:27:52 +01:00
ouyangxq 3f00896411 test(firstleg): active price book sailing channels 2026-09-29 04:21:46 +01:00
ouyangxq ed36c43af4 fix(firstleg): source sailing channels from active price book 2026-09-29 04:17:44 +01:00
liuyihe dd792cd792 贴标申请,仓储转运 2026-09-28 17:34:38 +08:00
admin a07bcda2c9 Merge pull request 'Test' (#18) from test into main
Reviewed-on: #18
2026-09-28 17:21:15 +08:00
ouyangxq 5816cd0958 优化 2026-09-28 17:20:39 +08:00
ouyangxq 6a3b6e22e6 优化 2026-09-28 17:17:28 +08:00
admin 3f65b5f0ae Merge pull request 'Test' (#17) from test into main
Reviewed-on: #17
2026-09-28 16:00:14 +08:00
ouyangxq 6954932ce3 fix(db): guard iam permission id conflicts 2026-09-28 15:45:45 +08:00
ouyangxq 08bf8f9a5a feat(db): add production migration chain 2026-09-28 15:39:15 +08:00
ouyangxq 7a4486b1bc test(db): align customs baseline and relabel contracts 2026-09-28 15:06:01 +08:00
ouyangxq f380d4ecb0 Merge remote-tracking branch 'origin/main' into test 2026-09-28 14:47:19 +08:00
ouyangxq 01efb9f157 docs: refresh repository guidance 2026-09-28 14:46:26 +08:00
ouyangxq 736e94c808 fix(firstleg): allow optional sailing booking details 2026-09-28 14:46:19 +08:00
ouyangxq e63cb2b29e fix(customs): add broker submission and receipt action history 2026-09-28 13:21:37 +08:00
ouyangxq ab64699245 fix(firstleg): preserve sailing edit metadata 2026-09-28 12:32:22 +08:00
liuyihe 582ea098f9 Merge remote-tracking branch 'origin/test' into test 2026-09-28 11:59:16 +08:00
liuyihe 2bb98d2047 贴标申请,仓储转运 2026-09-28 11:59:08 +08:00
ouyangxq f6e70ea5d0 feat(firstleg): expose customs receipt history 2026-09-28 09:49:53 +08:00
ouyangxq cf76f7d6d3 fix(firstleg): make sailing copy idempotent 2026-09-27 23:22:56 +08:00
ouyangxq 465d26ba42 feat(firstleg): project sailing display metrics read-only 2026-09-27 22:07:54 +08:00
ouyangxq 2c4f1ea37b fix(firstleg): project latest port schedule in container reads 2026-09-27 22:05:56 +08:00
ouyangxq a6ca9a8a18 fix(firstleg): project latest port schedule in platform containers 2026-09-27 21:56:04 +08:00
ouyangxq f1218cef02 fix(firstleg): avoid limit in latest schedule projection 2026-09-27 21:33:30 +08:00
ouyangxq ac3acfce6a fix(firstleg): project latest port schedule to merchant transport 2026-09-27 21:27:57 +08:00
ouyangxq 4bec639a23 fix(firstleg): open declaration batch after broker execution 2026-09-27 20:48:18 +08:00
ouyangxq 95da66c763 fix(firstleg): align declaration batch routes 2026-09-27 20:31:45 +08:00
ouyangxq ed692ee3bb feat(firstleg): model declaration batches and customs results 2026-09-27 20:23:53 +08:00
ouyangxq 69f2a18a44 fix(firstleg): restore sailing channel duplicate lookup 2026-09-27 19:56:23 +08:00
ouyangxq 48de46739c fix(firstleg): preserve sailing forecast batch facts 2026-09-27 19:23:56 +08:00
ouyangxq 4777f29c5b fix(firstleg): publish sailing edit and history paths 2026-09-27 19:20:10 +08:00
ouyangxq cdff6ba69d test(firstleg): wire sailing facts into measurement fixtures 2026-09-27 19:01:26 +08:00
ouyangxq 4525790534 feat(firstleg): add sailing calendar facts and APIs 2026-09-27 18:55:19 +08:00
ouyangxq 93e2e8fb44 test(customs): align receipt and final file contracts 2026-09-27 08:29:53 +08:00
ouyangxq 9f025edc96 fix(file): allow customs broker receipt files 2026-09-27 01:01:40 +08:00
ouyangxq 703b0f63ce fix(firstleg): restrict broker submission to tax refund cargo 2026-09-26 18:01:40 +08:00
ouyangxq 5f7df21f9c fix(firstleg): include receipt version in confirmation idempotency 2026-09-26 17:54:19 +08:00
ouyangxq 5927034b1c fix(firstleg): freeze customs receipt after broker submission 2026-09-26 17:45:41 +08:00
ouyangxq 5d365f9040 docs(firstleg): expose broker submission timestamp 2026-09-26 17:38:47 +08:00
ouyangxq c266cdee9a feat(firstleg): expose broker submission timestamp 2026-09-26 14:22:29 +08:00
ouyangxq 7680c3e5c2 docs(firstleg): align customs baseline schema 2026-09-26 14:11:28 +08:00
ouyangxq 12f87537d7 test(firstleg): preserve customs container row fixtures 2026-09-26 14:01:39 +08:00
ouyangxq e46d541780 fix(firstleg): map broker submission fields in customs container 2026-09-26 13:57:53 +08:00
ouyangxq ff2386e5bf feat(firstleg): split customs receipt and final document flow 2026-09-26 13:28:18 +08:00
ouyangxq 5a895bf214 test(firstleg): verify manual rollover schedule edit 2026-09-26 12:08:29 +08:00
ouyangxq 8940c4bcff test: align customs acceptance with receipt confirmation 2026-09-25 23:00:00 +08:00
ouyangxq bdf14dffb5 chore: rename Mojia permissions to weighing machine 2026-09-25 21:40:59 +08:00
ouyangxq 4e9663e852 test: cover persisted mismatched Mojia uploads 2026-09-24 17:24:01 +08:00
ouyangxq b31405af43 优化 2026-09-24 17:07:21 +08:00
ouyangxq 0e62b2f45b feat: release empty outbound pallet locations 2026-09-24 16:47:50 +08:00
ouyangxq f7b009bcdf feat: auto assign overseas pallet locations 2026-09-24 16:37:13 +08:00
ouyangxq 91464e031f 修正回执流程测试契约 2026-09-24 16:34:24 +08:00
ouyangxq 2486a01225 完善退税报关回执确认流程 2026-09-24 16:28:58 +08:00
liuyihe bcf44affd7 贴标申请,仓储转运 2026-09-24 14:42:24 +08:00
liuyihe 56f4e6e35c 贴标申请,仓储转运 2026-09-24 14:09:50 +08:00
liuyihe d03862d47e 贴标申请,仓储转运 2026-09-24 11:39:15 +08:00
liuyihe b991eac3c3 Merge branch 'dev' into test
# Conflicts:
#	lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java
2026-09-24 09:26:50 +08:00
liuyihe b89400e6dc 贴标申请,仓储转运 2026-09-24 09:13:02 +08:00
liuyihe 186b6be7d8 贴标申请,仓储转运 2026-09-24 09:01:14 +08:00
ouyangxq 49e63eca9f 优化 2026-09-24 08:54:26 +08:00
ouyangxq 66c2651be7 test: map PDA fixtures by completed container 2026-09-24 05:53:55 +08:00
ouyangxq c346c6b1ba test: preserve PDA producer identity for fixture handoff 2026-09-24 05:42:16 +08:00
ouyangxq 8f1fafe572 test: isolate overseas PDA operations fixture 2026-09-24 05:08:46 +08:00
ouyangxq eb8662070a test: harden admin acceptance fixtures and transport retries 2026-09-24 04:38:38 +08:00
ouyangxq f5e53db24f test: retry client order submit transport failures 2026-09-24 03:41:12 +08:00
ouyangxq d2fb8bb372 test: choose FBA fixture with spare relabel inventory 2026-09-24 03:23:34 +08:00
ouyangxq 7e0498af23 test: prefer overseas stock for PDA relabel 2026-09-24 02:58:40 +08:00
ouyangxq 83f78a4ecb test: combine same-destination PDA relabel sources 2026-09-24 02:49:32 +08:00
ouyangxq ca2d896403 test: preserve dispatch stock during PDA relabel 2026-09-24 02:37:39 +08:00
ouyangxq b78b878250 test: keep dispatch FBA inventory available 2026-09-24 02:04:00 +08:00
ouyangxq 4a5267d332 test: isolate overseas PDA relabel fixture 2026-09-24 01:48:24 +08:00
ouyangxq a0cf38f7b8 test: harden admin acceptance fixtures 2026-09-24 01:24:22 +08:00
ouyangxq ead27a6a11 test: retry transient phase3 PDA requests 2026-09-24 00:57:47 +08:00
ouyangxq d5d3364275 test: stabilize admin full acceptance flows 2026-09-24 00:04:03 +08:00
ouyangxq 7a8f9c8968 use idempotency key for finance export replay 2026-09-23 23:32:56 +08:00
ouyangxq 3a2a8d8f79 update PDA producer contract 2026-09-23 23:01:35 +08:00
ouyangxq 95537d2d8d include standard FBA producers in overseas PDA 2026-09-23 23:01:04 +08:00
ouyangxq e19a30cc16 derive primary FBA destination from producer matrix 2026-09-23 22:41:14 +08:00
ouyangxq 85379df182 exclude primary FBA destination for second producer 2026-09-23 22:20:18 +08:00
ouyangxq b1f97bef0f stabilize full admin fixture transport and state checks 2026-09-23 22:00:00 +08:00
ouyangxq 9ad0b9f4c3 dedupe FBA destinations before route selection 2026-09-23 21:58:01 +08:00
ouyangxq 98b7d89ab8 rotate second FBA producer destination 2026-09-23 21:29:21 +08:00
ouyangxq 2b71a00303 align transfer review fixtures with service states 2026-09-23 20:58:57 +08:00
ouyangxq a29f4fc749 retry merchant transfer submission on transport failure 2026-09-23 20:50:07 +08:00
ouyangxq 39cdfe1c8f submit merchant transfer drafts before review 2026-09-23 20:37:00 +08:00
ouyangxq 9a2b9c5d11 stabilize overseas admin acceptance fixtures 2026-09-23 20:28:26 +08:00
ouyangxq f3755b1348 fix overseas PDA producer fixture selection 2026-09-23 19:48:03 +08:00
ouyangxq 9c91bc0faa match PDA pending tasks by current container number 2026-09-23 19:39:31 +08:00
ouyangxq 9a29c1aeb0 retry PDA pending lookup without keyword 2026-09-23 19:36:45 +08:00
ouyangxq 99b3096633 fix pending task response syntax 2026-09-23 19:34:10 +08:00
ouyangxq e5952d79a7 read pending PDA tasks from paged response 2026-09-23 19:33:53 +08:00
ouyangxq 54a281b780 use PDA identity across downstream loading checks 2026-09-23 19:31:50 +08:00
ouyangxq b3b8ccaa18 keep loading task reassignment on admin identity 2026-09-23 19:11:39 +08:00
ouyangxq 8baefb0511 fix PDA identity handling in phase3 loading 2026-09-23 19:09:26 +08:00
ouyangxq 78bed591e0 use PDA token for evidence downloads 2026-09-23 18:50:06 +08:00
ouyangxq ed8efc9a35 use PDA identity for loading evidence acceptance 2026-09-23 18:48:27 +08:00
ouyangxq 7525754e03 preserve loading mutation contract for assigned pending tasks 2026-09-23 18:43:23 +08:00
ouyangxq d34bf88b7f fix PDA transport replacement and evidence auth 2026-09-23 18:40:32 +08:00
ouyangxq e58f087e97 同步Admin PDA全量验收协议 2026-09-23 18:32:23 +08:00
ouyangxq 5a6bfb2a30 优化 2026-09-23 18:26:04 +08:00
ouyangxq 6f36bf680f 修复预装确认后的PDA任务状态读取 2026-09-23 18:02:31 +08:00
ouyangxq 962dbe7c02 优化 2026-09-23 17:47:50 +08:00
ouyangxq 10a2ab3e42 优化 2026-09-23 17:45:54 +08:00
ouyangxq 0d145b84db test: align warehouse transfer destination contract 2026-09-23 13:10:04 +08:00
ouyangxq 64fbde2c90 test: mock domestic PDA operator in IAM controller contract 2026-09-23 12:01:01 +08:00
ouyangxq aa02ec9df8 test: align PDA operator warehouse contract 2026-09-23 10:56:42 +08:00
ouyangxq 267e32ba0f test: align domestic PDA operator mapper contract 2026-09-23 10:47:04 +08:00
ouyangxq 52fad8505b 优化 2026-09-23 09:59:39 +08:00
ouyangxq c26b68c53a Merge branch 'dev' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-23 08:58:58 +08:00
ouyangxq 5c01b34503 优化 2026-09-23 08:58:56 +08:00
liuyihe b21690d943 贴标申请,仓储转运 2026-09-23 08:41:53 +08:00
liuyihe 2555eda797 货柜pda 添加操作人 2026-09-22 17:27:32 +08:00
ouyangxq abca6882c3 fix: handle empty mojia sku sync filter 2026-09-22 14:36:07 +08:00
ouyangxq 81517945f6 优化 2026-09-22 14:26:46 +08:00
ouyangxq 2813be3581 test: verify lingxing track key vector 2026-09-22 13:33:43 +08:00
ouyangxq ab68334af3 feat: add read-only lingxing connection test 2026-09-22 11:44:06 +08:00
ouyangxq 212262e300 feat: control lingxing sku sync with nacos 2026-09-22 11:32:37 +08:00
ouyangxq 0c7ff73e2d docs: document lingxing new product measurement api 2026-09-22 10:16:31 +08:00
ouyangxq 16849c2d6e fix: register mojia sku sync gateway route 2026-09-22 10:01:32 +08:00
ouyangxq e696cf33a6 fix: cover A0 schema comment migration 2026-09-22 09:42:41 +08:00
ouyangxq 1ebc128c59 feat: support overseas warehouse locations and A0 overflow 2026-09-22 09:38:49 +08:00
ouyangxq d3ce268ca3 Merge remote-tracking branch 'origin/test' into dev 2026-09-22 09:33:00 +08:00
liuyihe 9a8412279c 贴标申请,仓储转运 2026-09-22 08:49:21 +08:00
ouyangxq 1ec8ef4e2b feat: add mojia sku sync permissions 2026-09-21 23:15:43 +08:00
ouyangxq 914c6fbebf feat: add mojia sku sync platform api 2026-09-21 23:07:21 +08:00
ouyangxq 74ed71bfc0 fix: derive platform qualification file names 2026-09-21 22:54:22 +08:00
ouyangxq cf09720beb fix: preserve qualification file names across uploads 2026-09-21 22:44:01 +08:00
ouyangxq 8d66610964 fix: wire lingxing auth components for spring 2026-09-21 22:12:37 +08:00
ouyangxq 03374c83f6 feat: prepare lingxing browser auth and sku measurement inbox 2026-09-21 21:20:32 +08:00
ouyangxq 656f197737 fix: fallback customer name for dimension initialization 2026-09-21 20:06:46 +08:00
ouyangxq 36f5aa4ae3 fix: allow measurement draft during processing 2026-09-21 19:58:01 +08:00
ouyangxq 9147190d67 fix: support manual FBA quote fallback 2026-09-21 10:44:23 +08:00
ouyangxq 8f2f485402 drain old web slot before blue-green shutdown 2026-09-21 09:26:50 +08:00
ouyangxq 3b4e288564 优化 2026-09-21 07:59:48 +08:00
ouyangxq 92286e18de disable blue-green deployment in test environment 2026-09-21 00:09:59 +08:00
admin d4919fe78b Merge pull request 'fix: keep BaoTa proxies on stable ports' (#16) from test into main 2026-09-20 23:41:50 +08:00
ouyangxq 2f0b421131 fix: keep BaoTa reverse proxies on stable ports 2026-09-20 23:39:39 +08:00
admin af37c150c0 Merge pull request 'fix: complete blue-green regression and test rollout' (#15) from test into main 2026-09-20 22:36:45 +08:00
ouyangxq c1983d47a5 fix: align full regression and enable test blue-green 2026-09-20 22:34:19 +08:00
admin 1eb4aa19df Merge pull request 'feat: production blue-green deployment for all business services' (#14) from test into main
Reviewed-on: #14
2026-09-20 22:22:40 +08:00
ouyangxq 9805bc7acc 优化 2026-09-20 22:21:02 +08:00
ouyangxq 2aa80f08d0 优化 2026-09-20 22:18:59 +08:00
ouyangxq 2bf3a1c8eb fix: bound blue-green worker memory 2026-09-20 22:00:10 +08:00
ouyangxq ea37015c80 优化 2026-09-20 21:52:07 +08:00
ouyangxq b4824ddf71 fix: harden blue-green routing and gateway migration 2026-09-20 21:49:30 +08:00
ouyangxq b50d59a058 feat: complete production blue-green deployment 2026-09-20 21:44:27 +08:00
ouyangxq e3415a8778 优化 2026-09-20 21:16:15 +08:00
ouyangxq f87fcf65de 优化 2026-09-20 20:53:20 +08:00
ouyangxq 799aa45a66 优化 2026-09-20 20:50:32 +08:00
ouyangxq 2213647db4 优化 2026-09-20 20:40:21 +08:00
ouyangxq 088c219c20 fix(acceptance): reuse approved carton mark during relabel rework 2026-09-20 20:20:49 +08:00
admin f09aa0923e Merge pull request 'Test' (#13) from test into main
Reviewed-on: #13
2026-09-20 20:00:22 +08:00
ouyangxq bd03ed5671 优化 2026-09-20 19:57:47 +08:00
ouyangxq db07111e63 优化 2026-09-20 19:48:49 +08:00
admin 4c7325e701 Merge pull request 'Test' (#12) from test into main
Reviewed-on: #12
2026-09-20 19:48:44 +08:00
ouyangxq 93b58fdd6d 优化 2026-09-20 19:45:24 +08:00
ouyangxq 80778e017e 优化 2026-09-20 19:33:51 +08:00
ouyangxq 6211230564 fix(lastleg): preserve mixed SKU balances when splitting return relabel stock 2026-09-20 19:11:53 +08:00
ouyangxq bf99bf9048 优化 2026-09-20 19:08:42 +08:00
ouyangxq 8ada2ef673 fix(lastleg): bind merged return inventory to reusable target pallet 2026-09-20 18:54:40 +08:00
ouyangxq 417cbc6363 优化 2026-09-20 18:51:10 +08:00
ouyangxq 20a748cf7f 优化 2026-09-20 18:25:12 +08:00
ouyangxq d8e368bf55 test(pda): update historical photo paths 2026-09-20 18:20:28 +08:00
ouyangxq 0fef9a8133 fix(pda): allow receiving photo lookup by container 2026-09-20 18:12:48 +08:00
liuyihe 82ff6589cb 贴标申请,仓储转运 2026-09-20 16:55:55 +08:00
ouyangxq 2ce2fff8d7 fix: map sealed pallet cartons to physical pallet 2026-09-20 16:10:01 +08:00
ouyangxq cb008ad64d test(firstleg): cover printable pallet without nickname 2026-09-20 15:57:51 +08:00
ouyangxq 0dec690235 优化 2026-09-20 15:49:29 +08:00
ouyangxq 75c2bd092f 优化 2026-09-20 15:39:37 +08:00
liuyihe 11e5c6b769 Merge remote-tracking branch 'origin/test' into test 2026-09-20 15:07:01 +08:00
liuyihe de5b1fa889 贴标申请,仓储转运 2026-09-20 15:06:56 +08:00
liuyihe 02844da360 贴标申请,仓储转运 2026-09-20 15:06:17 +08:00
ouyangxq 0e52f4e11e 优化x 2026-09-20 15:03:24 +08:00
ouyangxq a7d458cddd 优化 2026-09-20 14:43:26 +08:00
ouyangxq 398f4e6983 test: align database initialization contracts 2026-09-20 14:36:22 +08:00
ouyangxq 99b1156f96 优化 2026-09-20 14:34:02 +08:00
ouyangxq 784f7ea6cd 优化 2026-09-20 14:28:25 +08:00
ouyangxq b12e5c6ffa 优化 2026-09-20 14:26:49 +08:00
ouyangxq b3162c6d69 优化 2026-09-20 14:26:03 +08:00
ouyangxq b9e0c8ea2f 优化 2026-09-20 14:22:06 +08:00
ouyangxq 493d62e04c fix: keep pallet labels on A4 templates 2026-09-20 14:05:00 +08:00
ouyangxq ee97565f67 fix: paginate dropship pallet print details 2026-09-20 13:51:06 +08:00
admin 55eb30f065 Merge pull request 'fix(print): remove SKU from carton labels while retaining dropship A4 details' (#11) from test into main
Reviewed-on: #11
2026-09-20 11:24:49 +08:00
ouyangxq bbf000a38a fix(print): remove SKU from carton labels while retaining dropship A4 details 2026-09-20 11:22:29 +08:00
admin b7dc7ebbb0 Merge pull request '优化' (#10) from test into main
Reviewed-on: #10
2026-09-20 11:11:08 +08:00
ouyangxq 78a36d1a39 优化 2026-09-20 11:09:52 +08:00
admin 9360f973ef Merge pull request '优化' (#9) from test into main
Reviewed-on: #9
2026-09-20 10:48:10 +08:00
ouyangxq c909d9044e 优化 2026-09-20 10:45:27 +08:00
admin b5b57f6cc0 Merge pull request '优化' (#8) from test into main
Reviewed-on: #8
2026-09-20 10:35:17 +08:00
ouyangxq 507763a0d6 优化 2026-09-20 10:06:14 +08:00
admin fcff45d8a0 Merge pull request '优化' (#7) from test into main
Reviewed-on: #7
2026-09-20 09:19:57 +08:00
ouyangxq d5c4059942 优化 2026-09-20 09:17:47 +08:00
admin b4182f838c Merge pull request 'Test' (#6) from test into main
Reviewed-on: #6
2026-09-19 22:59:43 +08:00
ouyangxq aa2953172b 优化 2026-09-19 17:30:20 +08:00
ouyangxq 9a7b1bb2b8 优化 2026-09-19 17:02:04 +08:00
ouyangxq 4b69f13d76 优化 2026-09-18 18:50:45 +08:00
ouyangxq 28d3d6b8b6 优化 2026-09-18 18:50:40 +08:00
admin a519942cb3 Merge pull request 'Test' (#5) from test into main
Reviewed-on: #5
2026-09-18 18:04:39 +08:00
ouyangxq 1fb02db516 优化 2026-09-18 18:02:27 +08:00
ouyangxq aedfcc3abf 优化 2026-09-18 16:49:25 +08:00
ouyangxq fd27a0ed21 优化 2026-09-18 16:48:30 +08:00
ouyangxq 7d6ca17c02 fix(firstleg): execute order remarks sync SQL 2026-09-18 15:24:09 +08:00
ouyangxq 98d15c30bb fix: carry partial dropship measurements to outbound 2026-09-18 15:23:36 +08:00
ouyangxq c8a99f9fff fix: complete order measurement downstream flow 2026-09-18 14:58:40 +08:00
ouyangxq 02d2474141 test(lastleg): align merchant warehouse contract wording 2026-09-18 14:21:05 +08:00
ouyangxq 1b7ef17c89 test(lastleg): keep pallet regression fixture self-contained 2026-09-18 14:16:19 +08:00
ouyangxq 895d0f7639 fix(lastleg): accept cabinet pallet identities 2026-09-18 14:14:10 +08:00
ouyangxq 2d902308b5 fix(firstleg): align measurement freeze contract 2026-09-18 13:42:49 +08:00
ouyangxq b0c2e1d967 fix(firstleg): use short cabinet pallet identities 2026-09-18 13:37:46 +08:00
wujinliang 674cac8ec2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-18 11:41:13 +08:00
wujinliang 1fc3e04723 1 2026-09-18 11:41:04 +08:00
ouyangxq 387cd46a6b Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-18 09:59:08 +08:00
ouyangxq 4f59f7bd28 优化 2026-09-18 09:59:07 +08:00
ouyangxq 13a8dde0a5 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-18 09:59:04 +08:00
wujinliang 263068ac85 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-18 09:16:10 +08:00
wujinliang 6be4e764a9 1 2026-09-18 09:16:02 +08:00
ouyangxq 27d281d4b3 优化 2026-09-17 18:25:05 +08:00
admin e7436263d7 Merge pull request 'ci: remove duplicate production deploy approval' (#4) from test into main
Reviewed-on: #4
2026-09-17 16:02:46 +08:00
admin 662667aba3 Merge pull request 'Test' (#3) from test into main
Reviewed-on: #3
2026-09-17 15:27:43 +08:00
admin e147bec604 Merge pull request 'test(auth): align SQL contracts with clean initialization' (#2) from test into main
Reviewed-on: #2
2026-09-17 14:00:50 +08:00
admin eed95a384a Merge pull request 'Test' (#1) from test into main
Reviewed-on: #1
2026-09-17 11:52:43 +08:00
2666 changed files with 206852 additions and 22733 deletions
+1
View File
@@ -14,6 +14,7 @@ logs/
data/
.env
.env.*
.env.development
!.env.example
!.env.*.example
application-local.yml
-2
View File
@@ -89,7 +89,6 @@ OpenAPI有效性:
expire_in: 7 days
paths:
- gateway/target/*.jar
- gateway-overseas/target/*.jar
- auth/target/*.jar
- iam/target/*.jar
- order/target/*.jar
@@ -102,4 +101,3 @@ OpenAPI有效性:
- print/target/*.jar
- audit-service/target/*.jar
- integration-service/target/*.jar
- event-bridge-service/target/*.jar
+94 -68
View File
@@ -1,73 +1,87 @@
# 头程与海外仓后端项目指引
> 当前为单 TEST、统一 Gateway/Auth 会话/Nacos 与业务 Outbox 直接投递。架构规则及部署/验收状态唯一见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`。模块旧双区域说明不能覆盖这两份契约;构建、部署与整体验收分别核对。
根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。
## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;统一 Token、API 和可靠直接投递公共契约见 `../docs/统一认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 不可突破的产品与数据边界
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
| 任务关键词 | 首选服务 | 先看入口 |
|---|---|---|
| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 平台订单批量审核预览 | `order/` | `PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`BatchOrderReviewPreviewService.java`、`BatchOrderReviewPreviewResponse.java`、`order-v1.yaml`;一次请求固定用订单与货物两次批量查询返回同序快照,不允许前端逐票请求详情,逐项资格以 `canApprove/blockReason` 为准 |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` | `PlatformPriceBookService.java`、`ActivePriceBookEstimator.java`、`FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`、`InboundFeeDetailService.java`、`fba-pricing-v1.yaml`。`customerId=0` 为公共报价,正数为客户专属版本;计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。同柜区域重量阶梯按实际成员计费量聚合命中并守恒分摊,装柜变化只重算未锁定客户费用;人工计费量调整限基准上下 10%,私人仓尾程派送费只绑定 `PRIVATE` 入库单;一件代发 `WAIT_PALLETIZE` 允许在预装确认前试算,写费用时必须保留托盘化状态 |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 |
| 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合并分页返回全部稳定来源柜,已完成计划转换的柜仍用于接柜仓查询与修改,生成资格由 `allPlansReady/pendingPlanCount` 表达,详情返回全柜数据。`inbound-plans/container-preview` 由 `InboundPlanService.java` 返回服务端分组、托数、阻断项及快照版本/哈希;`ContainerInboundPlanConversionCoordinator.java` 校验同一快照并以唯一 Saga 先向 `WarehousePalletBatchService.java` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,失败只补偿未确认资源,同柜重放不得新建第二批。暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` | `exceptionorder/`、`inventory/command/`、`customervisit/`;异常按版本和幂等键执行 `PENDING → PROCESSING → COMPLETED`,完成证据固定归 `OVERSEAS_EXCEPTION_EVIDENCE`。派送退回明细只能来自原出库拣货事实,退回海外仓时校验真实目标托盘/库位并以 `RETURN_STOCK_IN` 恢复库存后,原子完成异常单与派送 `RETURNED`;回访仍以 Lastleg 客户范围和派送状态为准 |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`relabel/pda/`、`transfer/`、`discard/` 的 Merchant/PDA Controller 和 Service;换标下单按历史入仓库存选择,每个我方箱唛只形成一条箱任务,支持箱唛、SKU 和组合换标,SKU 子项不重复冻结。涉及箱唛换标时冻结派送方式,PDA 逐箱作业并上传完工照片,仓库复核后必须由客户查看证据确认,之后卡车进入预排车,自提/快递进入换标出库待创建;不伪造已入库单。Client 消费端 `../html/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;托盘作业保留托盘-入仓单组成明细,合托通过入仓计划托盘分配和合托记录留痕;`pallet-operations/relabel-disassemble` 按换标单箱唛快照解析原库存仓位,在一个幂等事务内移动对应冻结箱数并绑定已扫码目标托盘,同时更新换标明细和未开始拣货的出库托盘/箱唛快照;出库可扫描托盘但后台按托盘下各入仓单箱数核平;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
本表只保留定位入口;命中业务后用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
### 后端任务最小读取集
- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。
- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
| 任务关键词 | 首选服务 |
|---|---|
| 登录、Token、密码、注册与顶层授权快照 | `auth/` |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` |
| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` |
| 订单、货物、快照、状态、幂等、Outbox | `order/` |
| 客户头程订单中文批量导入 | `order/` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` |
| 平台订单批量审核预览 | `order/` |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` |
| 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` |
| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` |
| 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` |
| Firstleg 内部模块化与依赖基线 | `firstleg/` |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` |
| 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` |
| 客户入库结果与头程费用确认 | `firstleg/` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` |
| 目的港码头拖车与还空预警 | `firstleg/` |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` |
| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` |
| 客户运营统计、在途柜量 | `firstleg/` |
| 平台操作日志与审计查询 | `audit-service/` |
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` |
| 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` |
| 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` |
| 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` |
| 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` |
| 海外异常退回、库存恢复与客户回访 | `lastleg-service/`、`file/` |
| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
## 公共模块快速索引
@@ -88,26 +102,38 @@
## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
## 契约、数据库与验证入口
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增、移动 Mapper 或修改扫描配置后运行 `node scripts/mapper-scan-contract.mjs`;全量后端回归已在 Maven 前执行该门禁时复用结果,输入未变不重复执行。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前正式权限契约不保留旧独立 `*:view`;Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。若外部已有不可变权限契约,先按当前对话建立单独兼容规则。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
@@ -120,7 +146,7 @@
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 这些脚本会创建、更新或最终停用测试数据;指定测试环境和服务后,本轮非破坏性写入复用一次授权,删除、重置、SQL、部署或发布仍逐项授权,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
@@ -129,10 +155,10 @@
## 业务域文档入口
- 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
- 入库测量与计费:`../docs/入库测量与计费/README.md`、`../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/国内货柜履约开工门禁.md`、`../docs/国内货柜履约/国内货柜履约开发规划.md`、`../docs/国内货柜履约/国内货柜履约需求确认表.md`、页面与原型清单
- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
- 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务
- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
全量验收需要证据时再读取对应阶段报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+61 -71
View File
@@ -1,11 +1,10 @@
def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
]
def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY',
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG',
@@ -17,20 +16,19 @@ def serviceCheckboxes = [
'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT'
]
def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'deploy/docker-compose.yml', 'deploy/service-map.yml'
'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile'
]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
@@ -39,25 +37,25 @@ def commonPathServices = [
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
'firstleg', 'finance', 'lastleg-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
]
def databaseMigrationPaths = ['deploy/sql/']
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
def databaseSchemaContractEnabled = false
def downstreamResults = [:]
def downstreamBuildUrls = [:]
def downstreamBuildNumbers = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
if (!description?.startsWith('AUTO ')) return null
@@ -111,8 +109,6 @@ def environmentChoicesForJob(String jobBaseName) {
def fixedEnvironmentForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java-dev':
return 'development'
case 'backend-java':
return 'test'
case 'backend-java-prod':
@@ -124,8 +120,6 @@ def fixedEnvironmentForJob(String jobBaseName) {
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java-dev':
return 'baiyue-firstleg-dev-'
case 'backend-java':
return 'baiyue-firstleg-'
case 'backend-java-prod':
@@ -152,9 +146,8 @@ pipeline {
parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
@@ -164,14 +157,12 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
@@ -187,26 +178,24 @@ pipeline {
}
if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}"
error('部署环境和下游前缀必须与当前 Job 固定映射一致')
}
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2
exit 1
}
'''
}
// 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
if (!requestedRepository) {
error('必须填写真实镜像仓库地址')
}
if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
@@ -215,12 +204,8 @@ pipeline {
env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -281,19 +266,7 @@ pipeline {
}
selected = selected.unique()
if (params.DEPLOY && selected.contains('event-bridge-service')) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
if (bridgeRegion == 'SELECT') {
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
}
}
env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
@@ -326,6 +299,28 @@ pipeline {
}
}
stage('数据库结构只读门禁') {
when {
expression { databaseSchemaContractEnabled }
}
agent { label 'java-builder' }
steps {
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly"
withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) {
sh '''
set -eu
set -a
. "$SCHEMA_CHECK_ENV_FILE"
set +a
test "${SCHEMA_CHECK_READ_ONLY:-}" = "true"
node scripts/database-schema-contract.mjs --region BOTH
'''
}
}
}
}
stage('统一 Maven 测试门禁') {
when {
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
@@ -339,11 +334,15 @@ pipeline {
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
lock(resource: 'firstleg-test-maven') {
timeout(time: 50, unit: 'MINUTES') {
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
}
}
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
}
}
@@ -363,14 +362,6 @@ pipeline {
String service = selectedService
branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
boolean deployBothBridges = service == 'event-bridge-service' &&
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
String downstreamImageTag = deployBothBridges
? "${env.IMAGE_TAG}-both"
: env.IMAGE_TAG
String downstreamBridgeRegion = deployBothBridges
? 'CN'
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build(
job: jobName,
@@ -378,19 +369,18 @@ pipeline {
propagate: false,
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: downstreamImageTag),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE),
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
}
}
+62 -124
View File
@@ -1,11 +1,10 @@
def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
]
def serviceJars = [
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
@@ -17,18 +16,12 @@ def serviceJars = [
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
]
def fixedEnvironmentForServiceJob(String jobBaseName) {
if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) {
return 'development'
}
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) {
return 'production'
}
if (jobBaseName?.startsWith('baiyue-firstleg-')) {
if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production'
if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) {
return 'test'
}
return null
@@ -53,17 +46,16 @@ pipeline {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
environment {
REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
}
stages {
@@ -98,12 +90,7 @@ pipeline {
error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
}
if (params.ENVIRONMENT != resolvedEnvironment) {
echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}"
}
boolean deployBothBridges = service == 'event-bridge-service' &&
imageTag?.endsWith('-both')
if (deployBothBridges) {
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
error('部署环境必须与当前单服务 Job 固定映射一致')
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
@@ -114,6 +101,9 @@ pipeline {
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
if (imageRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
}
if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
@@ -123,18 +113,8 @@ pipeline {
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
if (service == 'event-bridge-service' && params.DEPLOY) {
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
}
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
}
}
sh '''
@@ -144,18 +124,16 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
sh '''
if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || {
echo "生产构建只允许已经合并到 main 的提交" >&2
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
echo "灰度发布提交必须已经合并到 main" >&2
exit 1
}
fi
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
@@ -167,17 +145,17 @@ pipeline {
steps {
script {
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
}
boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS
boolean skipTestExecution = params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (env.RESOLVED_ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
lock(resource: 'firstleg-test-maven') {
timeout(time: 50, unit: 'MINUTES') {
sh "sh deploy/scripts/run-ci-maven.sh --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
}
}
post {
@@ -220,51 +198,48 @@ pipeline {
}
}
stage('部署开发或测试环境') {
when {
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) }
}
stage('部署 TEST') {
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } }
agent none
steps {
script {
String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot"
String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
lock(resource: serviceDeployResource, inversePrecedence: true) {
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
node(targetLabel) {
deleteDir()
try {
unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}"
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
? ['CN', 'OVERSEAS']
: [params.EVENT_BRIDGE_REGION]
withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) {
bridgeRegions.each { bridgeRegion ->
if (env.RESOLVED_SERVICE == 'event-bridge-service') {
echo "部署事件桥实例: ${bridgeRegion}"
}
withEnv([
'TARGET_IS_LOCAL=true',
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
node('java-test') {
deleteDir()
unstash 'deployment-files'
withCredentials([
file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
]) {
withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
}
}
}
}
}
}
}
stage('部署临时灰度') {
when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } }
agent none
steps {
script {
lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
node('java-builder') {
deleteDir()
unstash 'deployment-files'
withCredentials([
file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
withEnv(['TARGET_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
"ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
sh 'sh deploy/scripts/deploy-built-service.sh production "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
}
}
}
@@ -273,43 +248,6 @@ pipeline {
}
}
stage('部署生产环境单个服务') {
when {
beforeAgent true
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' }
}
agent { label 'java-builder' }
steps {
deleteDir()
unstash 'deployment-files'
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env"
String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy"
lock(resource: serviceDeployResource, inversePrecedence: true) {
withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
withEnv([
'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services',
'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) {
sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
}
}
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
post {
+13 -12
View File
@@ -1,19 +1,22 @@
# 柏悦头程平台
本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。
根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
## 模块
- `gateway`:四端统一 API 入口。
- `finance`:财务账单、费用、充值、授信与资金核销。
- `auth`:统一登录、令牌签发与刷新。
- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
- `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:统一管理仓库内公共依赖版本。
@@ -29,21 +32,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。
## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖:
单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖:
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|---|---|---:|---:|---|
| 开发 | `json-file` | `20m` | `2` | 不要求 |
| 测试 | `json-file` | `50m` | `3` | 不要求 |
| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 |
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
@@ -51,7 +50,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
## 网关跨域配置
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
```text
CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
@@ -61,4 +60,6 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。
前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。
+6 -1
View File
@@ -1,5 +1,10 @@
# Audit Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
@@ -17,7 +22,7 @@
| 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
| 表结构 | `src/main/resources/db/audit.sql` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` |
## 修改与验证
+4
View File
@@ -53,6 +53,10 @@
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -6,13 +6,11 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUDIT_DB_USERNAME:root}
password: ${AUDIT_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
@@ -3,27 +3,27 @@ server:
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
active: ${SPRING_PROFILES_ACTIVE:test}
application:
name: audit-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD}
discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
server-addr: ${NACOS_SERVER_ADDR}
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
namespace: ${NACOS_NAMESPACE}
config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
namespace: ${NACOS_NAMESPACE:public}
server-addr: ${NACOS_SERVER_ADDR}
namespace: ${NACOS_NAMESPACE}
baiyue.audit.enabled: false
baiyue:
@@ -0,0 +1,51 @@
package com.baiyue.audit;
import com.baiyue.common.web.CommonWebAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AuditHealthEndpointTest {
private final WebApplicationContextRunner context = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
WebMvcAutoConfiguration.class, DispatcherServletAutoConfiguration.class, EndpointAutoConfiguration.class,
HealthEndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
ManagementContextAutoConfiguration.class, CommonWebAutoConfiguration.class))
.withPropertyValues("management.endpoints.web.exposure.include=health");
@Test
void actuatorHealthReturnsUpInsteadOfBusinessResourceNotFound() {
context.withBean("dependencyHealthIndicator", HealthIndicator.class, () -> () -> Health.up().build())
.run(application -> MockMvcBuilders.webAppContextSetup(application).build()
.perform(get("/actuator/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.code").doesNotExist()));
}
@Test
void failedHealthContributorReturnsDownAndServiceUnavailable() {
context.withBean("dependencyHealthIndicator", HealthIndicator.class, () -> () -> Health.down().build())
.run(application -> MockMvcBuilders.webAppContextSetup(application).build()
.perform(get("/actuator/health"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.status").value("DOWN"))
.andExpect(jsonPath("$.code").doesNotExist()));
}
}
+12 -5
View File
@@ -1,11 +1,16 @@
# Auth Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- 修改统一 Access/Refresh Token、Audience、JWKS、Redis 会话或权限撤销前,先读 `../../docs/统一认证与服务通信契约.md` 的认证章节;平台、客户及两类 PDA 保持各自访问域和仓绑定边界。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
@@ -16,9 +21,9 @@
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 |
| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -28,7 +33,9 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。
- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
+1
View File
@@ -22,6 +22,7 @@
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.baiyue.auth.client;
import feign.Request;
import feign.Retryer;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/** Only the IAM authorization client uses this bounded, single-attempt chain. */
public class AuthorizationFeignConfiguration {
@Bean public Request.Options authorizationRequestOptions() {
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false);
}
@Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; }
}
@@ -22,11 +22,27 @@ public class AuthorizationSnapshot {
private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
private Long tenantVersion;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions, Long permissionVersion,
Long overseasWarehouseId, Long domesticWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L);
}
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
permissionVersion, null, null);
}
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion, Long overseasWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, null);
}
}
@@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
/**
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
*/
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient",
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class,
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamAuthorizationClient {
@PostMapping("/authorization-snapshot")
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
public record AuthSecurityProperties(String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) {
}
@@ -1,22 +0,0 @@
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));
}
}
@@ -1,10 +0,0 @@
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) { }
@@ -3,7 +3,6 @@ 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;
@@ -11,27 +10,26 @@ 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) {
@EnableConfigurationProperties(UnifiedJwtProperties.class)
public class UnifiedJwtConfiguration {
@Bean("jwtEncoder")
JwtEncoder jwtEncoder(UnifiedJwtProperties 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 密钥");
throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
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("海外非当前密钥只能配置公钥");
throw new IllegalStateException("统一非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,7 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Unified RS256 signing key configuration. Private keys remain in Auth. */
@ConfigurationProperties(prefix = "baiyue.security.jwt")
public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { }
@@ -23,8 +23,6 @@ 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.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize;
/**
@@ -40,19 +38,6 @@ public class AuthController {
private MerchantRegistrationService registrationService;
@Resource
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")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前用户密码")
@@ -3,15 +3,12 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService;
import com.baiyue.auth.service.OverseasSessionValidationService;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -22,14 +19,13 @@ import java.security.MessageDigest;
/** 仅供可信内部服务调用的账号管理接口。 */
@RestController
@RequestMapping("/internal/auth/accounts")
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs")
public class InternalAccountController {
@Resource
private AccountManagementService service;
@Resource
private AuthService authService;
@Resource
private OverseasSessionValidationService overseasSessionValidationService;
@Resource private com.baiyue.auth.service.SessionStateService sessionStateService;
@Value("${baiyue.security.internal-service-token}")
private String configuredToken;
@@ -71,15 +67,17 @@ public class InternalAccountController {
@Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
}
@PostMapping("/overseas-session/validate")
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId,
@jakarta.validation.constraints.NotNull Long userId) { }
@PostMapping("/sessions/validate")
public ApiResponse<Boolean> validateSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
@Valid @RequestBody SessionValidationRequest request) {
verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request));
return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId()));
}
@PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
@@ -1,10 +1,9 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.config.UnifiedJwtProperties;
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;
@@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
/** 发布统一 JWT 的 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
private final UnifiedJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
public JwksController(UnifiedJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
@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());
@@ -1,29 +0,0 @@
package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */
public final class OverseasSessionValidationDtos {
private OverseasSessionValidationDtos() { }
@Schema(name = "OverseasSessionValidationRequest",
description = "Claims extracted only after overseas Gateway verifies the JWT signature")
public record Request(
@NotNull Long userId,
@NotNull Integer tokenVersion,
Long overseasWarehouseId) { }
@Schema(name = "OverseasSessionValidationResponse")
public record Response(boolean valid, FailureReason failureReason) {
public static Response accepted() { return new Response(true, null); }
public static Response rejected(FailureReason reason) { return new Response(false, reason); }
}
public enum FailureReason {
ACCOUNT_INVALID,
TOKEN_VERSION_MISMATCH,
WAREHOUSE_UNBOUND,
WAREHOUSE_MISMATCH
}
}
@@ -14,10 +14,6 @@ public record TokenResponse(
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn,
@Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
) {
@@ -19,13 +19,13 @@ public class AuthAccountRepository {
public AuthAccount findByUsername(String username) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)
.last("LIMIT 1"));
.last("LIMIT 1 FOR UPDATE"));
}
public AuthAccount findByPhone(String phone) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.last("LIMIT 1"));
.last("LIMIT 1 FOR UPDATE"));
}
public boolean existsByUsername(String username) {
@@ -53,13 +53,15 @@ public class AuthAccountRepository {
public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) {
return mapper.selectById(id);
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>().eq(AuthAccount::getId, id).last("FOR UPDATE"));
}
public AuthAccount findCurrentById(Long 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"));
.eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE"));
}
public void save(AuthAccount account) {
@@ -9,6 +9,7 @@ import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale;
@@ -63,7 +64,9 @@ public class AccountManagementService {
String appCode = request.appCode() == null ? "MERCHANT"
: request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId());
AuthAccount existing = repository.findById(request.accountId());
// Locking a missing key takes an InnoDB gap lock and can deadlock
// unrelated account inserts. The unique indexes arbitrate creation.
AuthAccount existing = repository.findCurrentById(request.accountId());
if (existing != null) {
if (sameIdentity(existing, request, appCode)) return summary(existing);
throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用");
@@ -88,7 +91,13 @@ public class AccountManagementService {
account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode(appCode);
account.setDeleted(0);
repository.insert(account);
try {
repository.insert(account);
} catch (DuplicateKeyException duplicate) {
AuthAccount winner = repository.findById(request.accountId());
if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner);
throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用");
}
return summary(account);
}
@@ -108,7 +117,7 @@ public class AccountManagementService {
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) {
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
}
@Transactional
@@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
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 org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import com.baiyue.auth.config.UnifiedJwtProperties;
import java.security.SecureRandom;
import java.time.Instant;
@@ -51,7 +51,7 @@ public class AuthService {
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private UnifiedJwtProperties signingProperties;
private final SecureRandom secureRandom = new SecureRandom();
@@ -92,7 +92,8 @@ public class AuthService {
AuthAccount account = accountRepository.findById(session.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
return issue(account);
if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
return issue(account, session.fingerprint());
}
@Transactional
@@ -109,8 +110,13 @@ public class AuthService {
accountRepository.save(account);
}
private TokenResponse issue(AuthAccount account) {
private TokenResponse issue(AuthAccount account) { return issue(account, null); }
private TokenResponse issue(AuthAccount account, String expectedState) {
AuthorizationSnapshot authorization = authorizationLoader.load(account);
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
}
Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
@@ -135,6 +141,9 @@ public class AuthService {
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
if (authorization.getDomesticWarehouseId() != null) {
claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
@@ -142,15 +151,11 @@ public class AuthService {
byte[] random = new byte[48];
secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(),
SessionStateService.fingerprint(account, authorization), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
@@ -204,18 +209,12 @@ public class AuthService {
.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));
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900,
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn(),
authorization.getRoles(), authorization.getPermissions());
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
String overseasAccessToken, long overseasExpiresIn,
java.util.Set<String> roles,
java.util.Set<String> authorities) {
public ImpersonationTokenResponse {
@@ -225,7 +224,7 @@ public class AuthService {
}
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build();
}
private static BizException invalidCredentials() {
@@ -1,38 +0,0 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.util.Objects;
/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */
@Service
public class OverseasSessionValidationService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
public Response validate(Request request) {
AuthAccount account = accounts.findById(request.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
return Response.rejected(FailureReason.ACCOUNT_INVALID);
}
int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion();
if (currentVersion != request.tokenVersion()) {
return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH);
}
Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId();
if (currentWarehouseId == null || request.overseasWarehouseId() == null) {
return Response.rejected(FailureReason.WAREHOUSE_UNBOUND);
}
if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) {
return Response.rejected(FailureReason.WAREHOUSE_MISMATCH);
}
return Response.accepted();
}
}
@@ -1,71 +0,0 @@
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);
return issue(account, authorization);
}
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(),
authorization.getRoles(), authorization.getPermissions());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn,
java.util.Set<String> roles, java.util.Set<String> authorities) {
public OverseasTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
}
@@ -0,0 +1,63 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
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 com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.TreeSet;
/** No cache: a validation started after a security commit must observe that commit. */
@Service
public class SessionStateService {
private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper();
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private TokenSessionStore sessions;
public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) {
try {
if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null
|| snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version");
// Ordered JSON array avoids set ordering and delimiter ambiguities.
Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(),
account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(),
snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(),
snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(),
new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())};
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(FINGERPRINT_JSON.writeValueAsBytes(state)));
} catch (Exception failure) {
throw new IllegalStateException("Cannot fingerprint authorization state", failure);
}
}
private void requireCurrent(Long userId, String expected) {
AuthAccount account = accounts.findCurrentById(userId);
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|| Integer.valueOf(1).equals(account.getDeleted()) || expected == null
|| !expected.equals(fingerprint(account, authorizationLoader.load(account)))) {
throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录");
}
}
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW,
isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public boolean validate(String jwtId, Long userId) {
TokenSessionStore.SessionState state = sessions.loadState(jwtId);
if (state == null || !state.userId().equals(userId)) {
throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效");
}
requireCurrent(userId, state.fingerprint());
return true;
}
}
@@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
@@ -21,8 +19,6 @@ import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import java.util.Set;
import java.util.ArrayList;
import java.util.List;
/**
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
@@ -42,7 +38,7 @@ public class TokenSessionStore {
}
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId);
@@ -50,25 +46,27 @@ public class TokenSessionStore {
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));
storeState(jwtId, userId, fingerprint, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint));
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet();
if (sets != null) sets.add(userKey, accessKey, refreshKey);
if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId));
redisTemplate.expire(userKey, refreshTtl);
} catch (Exception exception) {
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { }
try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { }
throw unavailable(exception);
}
}
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) {
Set<String> permissions, String fingerprint, 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);
storeState(jwtId, userId, fingerprint, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId));
redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) {
throw unavailable(exception);
@@ -103,6 +101,7 @@ public class TokenSessionStore {
if (value == null || value.isBlank()) return null;
RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
redisTemplate.delete(stateKey(session.accessJwtId()));
return session;
} catch (DataAccessException exception) {
throw unavailable(exception);
@@ -121,43 +120,26 @@ public class TokenSessionStore {
try {
Set<String> keys = redisTemplate.opsForSet().members(userKey);
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
List<String> legacyKeys = new ArrayList<>();
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
redisTemplate.delete(userKey);
} catch (DataAccessException exception) {
throw unavailable(exception);
}
}
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) {
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
if (cursor == null) return;
try (cursor) {
while (cursor.hasNext()) {
String key = cursor.next();
String value = redisTemplate.opsForValue().get(key);
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
result.add(key);
}
}
} catch (Exception exception) {
throw unavailable(exception);
}
private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; }
private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception {
if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state");
redisTemplate.opsForValue().set(stateKey(jwtId),
objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl);
}
private boolean belongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); }
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; }
public SessionState loadState(String jwtId) {
try {
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null;
String value = redisTemplate.opsForValue().get(stateKey(jwtId));
return value == null ? null : objectMapper.readValue(value, SessionState.class);
} catch (Exception failure) { throw unavailable(failure); }
}
private static Set<String> normalized(Set<String> values) {
@@ -182,7 +164,8 @@ public class TokenSessionStore {
}
}
public record RefreshSession(Long userId, String accessJwtId) { }
public record SessionState(Long userId, String fingerprint) { }
public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { }
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession {
roles = normalized(roles);
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${AUTH_DB_USERNAME:root}
password: ${AUTH_DB_PASSWORD:123456}
@@ -1,15 +0,0 @@
spring:
cloud:
nacos:
config:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
encode: UTF-8
discovery:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
+18 -18
View File
@@ -2,36 +2,35 @@ server:
port: ${AUTH_PORT:9100}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
active: ${SPRING_PROFILES_ACTIVE:test}
application:
name: auth-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
namespace: ${NACOS_NAMESPACE}
config:
namespace: ${NACOS_NAMESPACE:public}
namespace: ${NACOS_NAMESPACE}
encode: UTF-8
baiyue.security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
@@ -39,16 +38,17 @@ baiyue.security:
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5}
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}
jwt-mode: jwks
session-mode: redis
jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
jwt:
rsa-jwk-set: ${JWT_RSA_JWK_SET:}
current-kid: ${JWT_CURRENT_KID:}
baiyue.clients:
iam:
url: ${IAM_SERVICE_URL:}
management.endpoints.web.exposure.include: health,info,prometheus
baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
@@ -0,0 +1,39 @@
openapi: 3.0.3
info:
title: Auth internal session validation
version: 1.0.0
paths:
/internal/auth/accounts/sessions/validate:
post:
summary: Validate persisted account and IAM authorization state
description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token.
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jwtId, userId]
properties:
jwtId: {type: string, minLength: 1}
userId: {type: integer, format: int64}
responses:
'200':
description: Only string code 200 and boolean data true constitute success. Business errors deny access.
content:
application/json:
schema:
type: object
required: [code]
properties:
code: {type: string}
message: {type: string}
data: {type: boolean, nullable: true}
components:
securitySchemes:
internalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
@@ -0,0 +1,96 @@
package com.baiyue.auth.client;
import feign.Client;
import feign.FeignException;
import feign.Request;
import feign.Response;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.cloud.openfeign.FeignAutoConfiguration;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.FeignClientProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
class AuthorizationFeignConfigurationContractTest {
@Test
void actualNamedNacosConfigMatchesContextIdAndOverridesGlobalFeignBudget() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(FeignAutoConfiguration.class,
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class))
.withUserConfiguration(OfflineFeignConfiguration.class)
.withPropertyValues("baiyue.clients.iam.url=http://127.0.0.1:1")
.withInitializer(context -> {
for (String file : new String[]{"baiyue-common.yml", "auth-service.yml"}) {
Path path = Path.of("config", "nacos", "test", file);
if (!Files.isRegularFile(path)) path = Path.of("..").resolve(path);
try {
// Load real configuration as properties only, without config-data imports.
for (var source : new YamlPropertySourceLoader().load(file, new FileSystemResource(path))) {
context.getEnvironment().getPropertySources().addLast(source);
}
} catch (java.io.IOException failure) {
throw new IllegalStateException("Cannot read actual Auth Feign configuration", failure);
}
}
})
.run(context -> {
assertThat(context).hasNotFailed();
String contextId = IamAuthorizationClient.class.getAnnotation(FeignClient.class).contextId();
var properties = context.getBean(FeignClientProperties.class);
var named = properties.getConfig().get(contextId);
assertThat(named).as("Nacos key must match actual @FeignClient contextId").isNotNull();
assertThat(named.getConnectTimeout()).isEqualTo(1000);
assertThat(named.getReadTimeout()).isEqualTo(2000);
assertThat(named.isFollowRedirects()).isFalse();
assertThat(properties.getConfig().get("default").getConnectTimeout()).isEqualTo(3000);
assertThat(properties.getConfig().get("default").getReadTimeout()).isEqualTo(5000);
var transport = context.getBean(CapturingClient.class);
var client = context.getBean(IamAuthorizationClient.class);
var request = new AuthorizationSnapshotRequest(1L, 7L, 9L, "MERCHANT");
client.load("offline-service-token", request);
assertThat(transport.options.get().connectTimeoutMillis()).isEqualTo(1000);
assertThat(transport.options.get().readTimeoutMillis()).isEqualTo(2000);
assertThat(transport.options.get().isFollowRedirects()).isFalse();
transport.status.set(503);
assertThatThrownBy(() -> client.load("offline-service-token", request)).isInstanceOf(FeignException.class);
assertThat(transport.calls.get()).as("One success plus one failure, without automatic retry").isEqualTo(2);
});
}
@Configuration(proxyBeanMethods = false)
@EnableFeignClients(clients = IamAuthorizationClient.class)
static class OfflineFeignConfiguration {
@Bean CapturingClient capturingClient() { return new CapturingClient(); }
}
/** The real Feign factory builds the client; only network I/O is replaced. */
static class CapturingClient implements Client {
final AtomicReference<Request.Options> options = new AtomicReference<>();
final AtomicInteger calls = new AtomicInteger();
final AtomicInteger status = new AtomicInteger(204);
@Override public Response execute(Request request, Request.Options effectiveOptions) {
options.set(effectiveOptions);
calls.incrementAndGet();
return Response.builder().request(request).status(status.get()).reason("offline")
.headers(Map.of()).build();
}
}
}
@@ -10,14 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class AuthTokenTtlConfigurationContractTest {
@Test
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception {
String configuration = Files.readString(
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
Path.of("..", "config", "nacos", "test", "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("OVERSEAS_ACCESS_TOKEN_TTL")
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
}
}
@@ -1,29 +0,0 @@
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,43 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.*;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class UnifiedJwtConfigurationTest {
@Test
void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate();
var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate();
var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current");
var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties);
var now = Instant.now();
var token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(),
JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build()));
var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue());
assertThat(decoded.getSubject()).isEqualTo("42");
assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256");
assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue()))
.isInstanceOf(JwtException.class);
}
@Test
void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").generate();
var previous = new RSAKeyGenerator(2048).keyID("previous").generate();
var config = new UnifiedJwtConfiguration();
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current")))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current")))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current")))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -1,6 +1,6 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.config.UnifiedJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
@@ -20,8 +20,7 @@ class JwksControllerTest {
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 properties = new UnifiedJwtProperties(json, "current");
var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
@@ -1,28 +0,0 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
import io.swagger.v3.oas.annotations.Operation;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OverseasSessionValidationControllerContractTest {
@Test
void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() {
var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("validateOverseasSession"))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/overseas-session/validate");
assertThat(method.getAnnotation(Operation.class).description())
.contains("Gateway JWT verification", "raw tokens and user credentials are forbidden");
assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("userId", "tokenVersion", "overseasWarehouseId")
.doesNotContain("token", "accessToken", "authorization", "password");
}
}
@@ -20,6 +20,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.dao.DuplicateKeyException;
import jakarta.annotation.Resource;
@@ -85,7 +86,7 @@ class AccountManagementServiceTest {
}
@Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() {
when(repository.findById(20L)).thenReturn(null);
when(repository.findCurrentById(20L)).thenReturn(null);
when(repository.existsByUsername("platform.user")).thenReturn(false);
var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1",
@@ -97,6 +98,7 @@ class AccountManagementServiceTest {
assertEquals("PLATFORM", captor.getValue().getDefaultAppCode());
assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash()));
assertNotEquals("Password1", captor.getValue().getPasswordHash());
verify(repository, never()).findById(20L);
}
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
@@ -107,7 +109,7 @@ class AccountManagementServiceTest {
}
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
when(repository.findById(20L)).thenReturn(null);
when(repository.findCurrentById(20L)).thenReturn(null);
when(repository.existsByUsername("merchant.user")).thenReturn(false);
var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1",
@@ -141,6 +143,7 @@ class AccountManagementServiceTest {
account.setUsername("platform.user"); account.setDisplayName("平台用户");
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
when(repository.findCurrentById(10L)).thenReturn(account);
var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
"平台用户", 0L, 30L, "PLATFORM"));
@@ -149,6 +152,44 @@ class AccountManagementServiceTest {
verify(repository, never()).insert(any());
}
@Test void concurrentInsertOfSameIdentityReturnsDurableWinner() {
account.setUsername("platform.user"); account.setDefaultTenantId(0L);
account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L);
account.setDefaultAppCode("PLATFORM"); account.setDeleted(0);
when(repository.findCurrentById(10L)).thenReturn(null);
doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any());
var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
"平台用户", 0L, 30L, "PLATFORM"));
assertEquals(10L, result.accountId());
var order = inOrder(repository);
order.verify(repository).findCurrentById(10L);
order.verify(repository).existsByUsername("platform.user");
order.verify(repository).insert(any());
order.verify(repository).findById(10L);
verify(repository, never()).save(any());
}
@Test void concurrentInsertOfDifferentIdentityIsRejected() {
when(repository.findCurrentById(10L)).thenReturn(null);
doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any());
assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(10L,
"platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")));
verify(repository, never()).save(any());
}
@Test void concurrentUsernameConflictDoesNotCreateOrReturnAnotherAccount() {
when(repository.findCurrentById(20L)).thenReturn(null);
when(repository.findById(20L)).thenReturn(null);
doThrow(new DuplicateKeyException("username conflict")).when(repository).insert(any());
assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(20L,
"platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")));
verify(repository, never()).save(any());
}
@Test void synchronizeDisplayNameRevokesExistingSessions() {
account.setDisplayName("旧名称");
@@ -12,7 +12,7 @@ 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.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -37,21 +37,24 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
com.baiyue.auth.config.UnifiedJwtProperties signingProperties() {
return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current");
}
@Bean
AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
return new AuthSecurityProperties("auth-service", "lastleg-api",
Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10));
}
}
@Test
void signsCrossRegionClaimsAndStoresSession() {
void signsUnifiedClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
@@ -71,9 +74,6 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.login(new LoginRequest("operator", "secret"));
@@ -89,16 +89,13 @@ class AuthServiceSmallJwtTest {
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactly("platform:order:list");
}
@@ -144,7 +141,7 @@ class AuthServiceSmallJwtTest {
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
@@ -159,14 +156,11 @@ class AuthServiceSmallJwtTest {
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot)));
when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900,
snapshot.getRoles(), snapshot.getPermissions()));
var response = service.refresh("refresh-old");
@@ -175,19 +169,16 @@ class AuthServiceSmallJwtTest {
assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("MERCHANT_ADMIN");
assertThat(response.authorities()).containsExactly("merchant:order:list");
verify(tokenSessionStore).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
@@ -200,7 +191,6 @@ class AuthServiceSmallJwtTest {
verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
}
@Test
@@ -225,14 +215,9 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900,
Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")));
AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
assertThat(response.authorities()).containsExactly("merchant:order:list");
@@ -246,7 +231,6 @@ class AuthServiceSmallJwtTest {
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)));
verify(overseasTokenService).issue(account, snapshot);
anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
}
}
@@ -1,77 +0,0 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason;
import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class OverseasSessionValidationServiceTest {
private final AuthAccountRepository accounts = mock(AuthAccountRepository.class);
private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class);
private final OverseasSessionValidationService service = new OverseasSessionValidationService();
private AuthAccount account;
@BeforeEach
void setUp() throws Exception {
inject("accounts", accounts);
inject("authorizationLoader", authorizationLoader);
account = new AuthAccount();
account.setId(42L);
account.setAccountStatus("ACTIVE");
account.setTokenVersion(8);
when(accounts.findById(42L)).thenReturn(account);
}
@Test
void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() {
assertThat(service.validate(new Request(42L, 7, 900L)))
.extracting("valid", "failureReason")
.containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH);
}
@Test
void newlyIssuedTokenWithCurrentWarehousePasses() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue();
}
@Test
void missingOrChangedWarehouseBindingFailsClosed() {
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null));
assertThat(service.validate(new Request(42L, 8, null)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_UNBOUND);
when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L));
assertThat(service.validate(new Request(42L, 8, 900L)).failureReason())
.isEqualTo(FailureReason.WAREHOUSE_MISMATCH);
}
@Test
void disabledAccountFailsClosedBeforeIamLookup() {
account.setAccountStatus("DISABLED");
assertThat(service.validate(new Request(42L, 8, 901L)).failureReason())
.isEqualTo(FailureReason.ACCOUNT_INVALID);
}
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
}
private void inject(String fieldName, Object value) throws Exception {
var field = OverseasSessionValidationService.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(service, value);
}
}
@@ -1,139 +0,0 @@
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.times;
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(999L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
String authority = "platform:overseas-master-data:quote:list";
String merchantAuthority = "merchant:warehouse:delivery:list";
String merchantCrossBorderAuthority = "merchant:cross-border:procurement";
String lastlegAuthority = "lastleg:pda:operate";
String financeAuthority = "platform:finance:fee-source:edit";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority,
financeAuthority, domesticAuthority), 7L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
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("user_id", 42L).containsEntry("tenant_id", 0L)
.containsEntry("membership_id", 84L)
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L)
.containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode",
"permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority,
merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority);
}
@Test
void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() {
AuthAccount account = new AuthAccount();
account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L);
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
Jwt first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second);
when(first.getTokenValue()).thenReturn("overseas-old-binding");
when(second.getTokenValue()).thenReturn("overseas-new-binding");
var oldToken = service.issue(account, oldBinding);
var newToken = service.issue(account, newBinding);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder, times(2)).encode(captor.capture());
var oldClaims = captor.getAllValues().get(0).getClaims();
var newClaims = captor.getAllValues().get(1).getClaims();
assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId());
assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L)
.containsEntry("permission_version", 8L);
assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L)
.containsEntry("permission_version", 9L);
assertThat(newClaims.getClaims()).containsEntry("token_version", 8);
assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken());
}
@Test
void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() {
AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
service.issue(account, unbound);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
assertThat(captor.getValue().getClaims().getClaims())
.doesNotContainKey("overseas_warehouse_id");
}
}
@@ -0,0 +1,281 @@
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.config.UnifiedJwtProperties;
import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest;
import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services.
* Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */
class SessionStateConcurrencyTest {
JdbcTemplate authDb, iamDb;
DataSourceTransactionManager authTx, iamTx;
AuthService auth;
AccountManagementService accounts;
SessionStateService states;
TokenSessionStore sessions;
JwtEncoder encoder;
AtomicBoolean redisFailure;
final ConcurrentMap<String, String> values = new ConcurrentHashMap<>();
final ConcurrentMap<String, Set<String>> sets = new ConcurrentHashMap<>();
@BeforeEach void setup() throws Exception {
authDb = database(); iamDb = database();
authTx = new DataSourceTransactionManager(authDb.getDataSource());
iamTx = new DataSourceTransactionManager(iamDb.getDataSource());
authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50),"
+ "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT,"
+ "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT,"
+ "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT,"
+ "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id,"
+ "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)");
iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)");
iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)");
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
factory.setDataSource(authDb.getDataSource());
var config = new com.baomidou.mybatisplus.core.MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
factory.setConfiguration(config);
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject())
.getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class);
var repository = new AuthAccountRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
AuthorizationLoader loader = mock(AuthorizationLoader.class);
when(loader.load(any())).thenAnswer(call -> {
var tx = new TransactionTemplate(iamTx);
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ);
return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> {
if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled");
return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"),
Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null,
rs.getLong("tenant_version"));
}));
});
sessions = new TokenSessionStore();
ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(sessions, "keyPrefix", "test");
ReflectionTestUtils.setField(sessions, "redisTemplate", redis());
PasswordEncoder passwords = mock(PasswordEncoder.class);
when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1)));
when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0));
encoder = mock(JwtEncoder.class);
when(encoder.encode(any())).thenAnswer(c -> {
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
});
var service = new AuthService();
ReflectionTestUtils.setField(service, "accountRepository", repository);
ReflectionTestUtils.setField(service, "authorizationLoader", loader);
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
ReflectionTestUtils.setField(service, "passwordEncoder", passwords);
ReflectionTestUtils.setField(service, "jwtEncoder", encoder);
ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1)));
ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid"));
auth = transactional(service);
var management = new AccountManagementService();
ReflectionTestUtils.setField(management, "repository", repository);
ReflectionTestUtils.setField(management, "tokenSessionStore", sessions);
ReflectionTestUtils.setField(management, "passwordEncoder", passwords);
accounts = transactional(management);
var state = new SessionStateService();
ReflectionTestUtils.setField(state, "accounts", repository);
ReflectionTestUtils.setField(state, "authorizationLoader", loader);
ReflectionTestUtils.setField(state, "sessions", sessions);
states = transactional(state);
}
@Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception {
var revoked = new CountDownLatch(1);
var snapshotLoaded = new CountDownLatch(1);
var committed = new CountDownLatch(1);
doAnswer(c -> {
snapshotLoaded.countDown(); await(committed);
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
}).when(encoder).encode(any());
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<?> change = pool.submit(() -> {
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
accounts.revokeSessions(Set.of(1L));
revoked.countDown(); await(snapshotLoaded);
});
committed.countDown();
});
await(revoked);
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS);
// Access and state were written AFTER revoke, yet the first post-commit request is denied.
assertThat(values).containsKey("test:access:" + stale.accessToken());
assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
var fresh = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
assertThat(fresh.authorities()).containsExactly("read");
assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class);
} finally { committed.countDown(); pool.shutdownNow(); }
}
@Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception {
var loaded = new CountDownLatch(1); var release = new CountDownLatch(1);
doAnswer(c -> {
loaded.countDown(); await(release);
var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims();
return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build();
}).when(encoder).encode(any());
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password")));
await(loaded);
var resetting = new CountDownLatch(1);
var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); });
await(resetting);
assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
release.countDown();
var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS);
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
} finally { release.countDown(); pool.shutdownNow(); }
}
@Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() {
var old = auth.login(new LoginRequest("operator", "old-password"));
redisFailure.set(true);
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1");
accounts.revokeSessions(Set.of(1L));
})).isInstanceOf(BizException.class);
redisFailure.set(false);
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1);
assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1);
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
}
@Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() {
var old = auth.login(new LoginRequest("operator", "old-password"));
new TransactionTemplate(iamTx).executeWithoutResult(tx ->
iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1"));
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class);
var current = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(current.accessToken(), 1L)).isTrue();
iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1");
assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class);
}
@Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() {
var old = auth.login(new LoginRequest("operator", "old-password"));
new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
tx.setRollbackOnly();
});
assertThat(states.validate(old.accessToken(), 1L)).isTrue();
assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class);
var refreshed = auth.refresh(old.refreshToken());
assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue();
}
@Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() {
var old = auth.login(new LoginRequest("operator", "old-password"));
assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> {
iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1");
accounts.revokeSessions(Set.of(1L));
throw new IllegalStateException("Auth response timed out after its commit");
})).isInstanceOf(IllegalStateException.class);
assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write");
assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class);
var fresh = auth.login(new LoginRequest("operator", "old-password"));
assertThat(states.validate(fresh.accessToken(), 1L)).isTrue();
assertThat(fresh.authorities()).containsExactly("write");
}
@Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() {
var old = auth.login(new LoginRequest("operator", "old-password"));
// No Redis deletion: the authoritative identity gate alone closes the session.
iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1");
assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class);
iamDb.update("UPDATE authorization_state SET active=true WHERE id=1");
var fresh = auth.login(new LoginRequest("operator", "old-password"));
iamDb.execute("DROP TABLE authorization_state");
assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class);
assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class);
}
@SuppressWarnings("unchecked") private StringRedisTemplate redis() {
redisFailure = new AtomicBoolean();
var redis = mock(StringRedisTemplate.class);
ValueOperations<String,String> ops = mock(ValueOperations.class);
SetOperations<String,String> index = mock(SetOperations.class);
when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index);
doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class));
when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0)));
when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)));
when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0)));
when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null);
when(redis.delete(anyCollection())).thenAnswer(c -> {
if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline");
long count = 0; for (Object key : (java.util.Collection<?>) c.getArgument(0)) if (values.remove(key) != null) count++;
return count;
});
when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of()));
doAnswer(c -> {
Set<String> members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet());
for (int i=1; i<c.getArguments().length; i++) members.add(c.getArgument(i));
return 1L;
}).when(index).add(anyString(), any(String[].class));
return redis;
}
static JdbcTemplate database() {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=3000");
return new JdbcTemplate(ds);
}
@SuppressWarnings("unchecked") <T> T transactional(T target) {
var factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource()));
return (T) factory.getProxy();
}
static void await(CountDownLatch latch) {
try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); }
}
}
@@ -47,7 +47,7 @@ class TokenSessionStoreTest {
when(redis.opsForValue()).thenReturn(values);
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
"state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
verify(values).set(eq("baiyue:auth:access:jti-1"),
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
@@ -64,7 +64,7 @@ class TokenSessionStoreTest {
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
String json = new ObjectMapper().writeValueAsString(
new TokenSessionStore.RefreshSession(42L, "jti-1"));
new TokenSessionStore.RefreshSession(42L, "jti-1", "state"));
when(values.getAndDelete(anyString())).thenReturn(json);
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
@@ -7,14 +7,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计 Outbox 的 RocketMQ 发布配置。
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
*/
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
@EnableScheduling
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
@ConditionalOnBean(type = {
"com.baiyue.common.audit.AuditOutboxRepository",
+9 -1
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
@@ -14,11 +19,14 @@
| 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` |
| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` |
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` |
| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 |
| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 |
| 逐次投递审计事务 | `src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java`;领取与STARTED、持有者CAS完成与审计各自同库REQUIRES_NEW事务;HTTP在两个事务之间执行,迟到结果不得写入;JDBC为optional依赖,Print等无数据库服务不被引入JDBC |
| 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` |
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约。当前正式模型不保留旧序列化或数据库兼容分支;若已有不可变外部契约,先单独定义升级规则。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
+3
View File
@@ -14,6 +14,7 @@
<name>baiyue-common-core</name>
<dependencies>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-annotations</artifactId></dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-web</artifactId></dependency>
<dependency>
<groupId>com.baomidou</groupId>
@@ -24,5 +25,7 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId><optional>true</optional></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
</project>
@@ -1,5 +1,7 @@
package com.baiyue.common.core.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
/**
@@ -8,7 +10,7 @@ import java.time.Instant;
public record ApiResponse<T>(
String code,
String message,
T data,
@JsonInclude(JsonInclude.Include.ALWAYS) T data,
String requestId,
String traceId,
Instant timestamp) {
@@ -0,0 +1,59 @@
package com.baiyue.common.core.outbox;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Set;
import java.util.function.BooleanSupplier;
import java.util.function.IntSupplier;
/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */
public final class EventDeliveryJournal {
public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED }
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
this.jdbc = Objects.requireNonNull(jdbc);
if (!(manager instanceof DataSourceTransactionManager local)
|| local.getDataSource() != jdbc.getDataSource()) {
throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager");
}
transaction = new TransactionTemplate(manager);
transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
public boolean begin(String attempt, String service, String eventType, String entryId,
String eventId, String target, LocalDateTime now, BooleanSupplier claim) {
return Boolean.TRUE.equals(transaction.execute(status -> {
if (!claim.getAsBoolean()) return false;
jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')",
attempt, service, eventType, entryId, eventId, target, now);
return true;
}));
}
public boolean complete(String attempt, Outcome outcome, LocalDateTime now,
String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) {
if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "INVALID_RESPONSE", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode)
&& !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code");
return Boolean.TRUE.equals(transaction.execute(status -> {
int changed = updateOwnedOutbox.getAsInt();
if (changed == 0) return false; // A stale worker has no right to change either record.
if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count");
LocalDateTime started = jdbc.queryForObject(
"SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE",
LocalDateTime.class, attempt);
long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis());
int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'",
now, duration, outcome.name(), errorCode, nextRetry, attempt);
if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing");
return true;
}));
}
}
@@ -0,0 +1,4 @@
package com.baiyue.common.core.outbox;
/** Marks services that require durable attempt audit and cached delivery metrics. */
public interface EventDeliveryProducer { }
@@ -0,0 +1,20 @@
package com.baiyue.common.core.outbox;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Dedicated operations credential; a missing or weak configuration disables this capability. */
public final class EventOperationsTokenGuard {
private final byte[] expected;
public EventOperationsTokenGuard(String token) {
expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
}
public void require(String supplied) {
if (expected.length < 32 || supplied == null || supplied.length() > 4096
|| !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用");
}
}
}
@@ -0,0 +1,131 @@
package com.baiyue.common.core.outbox;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */
public final class OutboxOperations {
public record Layout(String table, String idColumn, String statusColumn, Set<String> eventTypes) {
public Layout {
for (String name : List.of(table, idColumn, statusColumn)) {
if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier");
}
eventTypes = Set.copyOf(eventTypes);
if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required");
}
}
public record Identity(String eventId, String eventType, String payloadHash) { }
public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { }
public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { }
public record Entry(String entryId, String eventId, String eventType, String payloadHash,
String status, int failures, String lastError, Object nextRetryAt) { }
private final JdbcTemplate jdbc;
private final Layout layout;
private final Function<String, Identity> identityReader;
private final TransactionTemplate transaction;
public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout,
Function<String, Identity> identityReader) {
this.jdbc = jdbc;
this.layout = layout;
this.identityReader = identityReader;
this.transaction = new TransactionTemplate(transactions);
}
public Entry inspect(String entryId) {
validateId(entryId);
Map<String, Object> row = find(entryId, false);
Identity identity = identity(row);
return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(),
String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(),
(String) row.get("last_error"), row.get("next_retry_at"));
}
public RetryResult retry(String entryId, RetryRequest request) {
validateId(entryId);
validateRequest(request);
try {
return transaction.execute(status -> {
Map<String, Object> row = find(entryId, true);
String canonicalId = String.valueOf(row.get("entry_id"));
Identity identity = identity(row);
if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) {
throw conflict("事件摘要已变化,拒绝重试");
}
List<Map<String, Object>> prior = jdbc.queryForList(
"SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?",
request.requestId());
if (!prior.isEmpty()) {
Map<String, Object> audit = prior.get(0);
if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id"))
|| !identity.eventId().equals(audit.get("event_id"))
|| !identity.payloadHash().equals(audit.get("payload_hash"))
|| !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) {
throw conflict("操作请求编号已用于不同重试参数");
}
return result(canonicalId, identity, request, true);
}
if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件");
// Relay due-time comparisons use UTC, independently of the JVM default zone.
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn()
+ "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE "
+ layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId);
if (updated != 1) throw conflict("事件状态已变化");
jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(),
request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now);
return result(canonicalId, identity, request, false);
});
} catch (DuplicateKeyException duplicate) {
throw conflict("操作请求编号冲突,原事务已回滚");
}
}
private Map<String, Object> find(String id, boolean lock) {
List<Map<String, Object>> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn()
+ " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE "
+ layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在");
return rows.get(0);
}
private Identity identity(Map<String, Object> row) {
Identity identity = identityReader.apply(String.valueOf(row.get("payload_json")));
if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口");
return identity;
}
private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) {
return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate);
}
private static void validateId(String entryId) {
if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效");
}
private static void validateRequest(RetryRequest request) {
if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空");
try {
if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException();
} catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); }
requireText(request.operator(), 128, "操作人");
requireText(request.reason(), 500, "原因");
if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) {
throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要");
}
}
private static void requireText(String value, int length, String label) {
if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) {
throw new BizException(ResultCode.BAD_REQUEST, label + "无效");
}
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
@@ -0,0 +1,31 @@
package com.baiyue.common.core.retry;
import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;
/** Bounded exponential retry schedule; callers persist failed or expired attempts. */
public record BoundedRetryPolicy(int maxAttempts, long baseDelaySeconds, long maxDelaySeconds, double jitter) {
public BoundedRetryPolicy {
if (maxAttempts < 1 || baseDelaySeconds < 1 || maxDelaySeconds < baseDelaySeconds
|| maxDelaySeconds > 86400 || !Double.isFinite(jitter) || jitter < 0 || jitter > 0.5) {
throw new IllegalArgumentException("Invalid bounded retry policy");
}
}
public boolean exhausted(int failedAttempts) {
return failedAttempts >= maxAttempts;
}
public Duration delayAfterFailure(int failedAttempts) {
return delayAfterFailure(failedAttempts, ThreadLocalRandom.current().nextDouble());
}
public Duration delayAfterFailure(int failedAttempts, double random) {
if (failedAttempts < 1 || !Double.isFinite(random) || random < 0 || random > 1) {
throw new IllegalArgumentException("Invalid retry attempt or jitter sample");
}
long seconds = baseDelaySeconds;
for (int i = 1; i < failedAttempts && seconds < maxDelaySeconds; i++) {
seconds = Math.min(maxDelaySeconds, seconds * 2);
}
long millis = Math.round(seconds * 1000 * (1 - jitter + 2 * jitter * random));
return Duration.ofMillis(Math.min(maxDelaySeconds * 1000, Math.max(1, millis)));
}
}
@@ -0,0 +1,31 @@
package com.baiyue.common.core;
import com.baiyue.common.core.retry.BoundedRetryPolicy;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class BoundedRetryPolicyTest {
@Test void startsAtFiveSecondsDoublesAndCapsAtThirtyMinutes() {
var policy = new BoundedRetryPolicy(12, 5, 1800, 0);
assertThat(policy.delayAfterFailure(1).toSeconds()).isEqualTo(5);
assertThat(policy.delayAfterFailure(2).toSeconds()).isEqualTo(10);
assertThat(policy.delayAfterFailure(9).toSeconds()).isEqualTo(1280);
assertThat(policy.delayAfterFailure(10).toSeconds()).isEqualTo(1800);
assertThat(policy.delayAfterFailure(Integer.MAX_VALUE).toSeconds()).isEqualTo(1800);
assertThat(policy.exhausted(11)).isFalse();
assertThat(policy.exhausted(12)).isTrue();
}
@Test void jitterStaysBoundedEvenAtMaximumDelay() {
var policy = new BoundedRetryPolicy(12, 5, 1800, 0.1);
assertThat(policy.delayAfterFailure(1, 0).toMillis()).isEqualTo(4500);
assertThat(policy.delayAfterFailure(1, 1).toMillis()).isEqualTo(5500);
assertThat(policy.delayAfterFailure(20, 1).toSeconds()).isEqualTo(1800);
assertThat(policy.delayAfterFailure(20, 0).toSeconds()).isEqualTo(1620);
}
@Test void invalidConfigurationFailsClosed() {
assertThatThrownBy(() -> new BoundedRetryPolicy(0, 5, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 0, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 3, 0.1)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 1800, Double.NaN)).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,83 @@
package com.baiyue.common.core;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class EventDeliveryJournalTest {
JdbcTemplate jdbc;
EventDeliveryJournal journal;
DataSourceTransactionManager transactions;
@BeforeEach void setup() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
transactions = new DataSourceTransactionManager(source);
journal = new EventDeliveryJournal(jdbc, transactions);
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))");
jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)");
}
boolean begin(String owner) {
return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(),
() -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1);
}
int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); }
String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); }
String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); }
@Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() {
new TransactionTemplate(transactions).executeWithoutResult(status -> {
assertThat(begin("a")).isTrue();
// Another connection sees STARTED before any HTTP would be invoked.
var other = new JdbcTemplate(jdbc.getDataSource());
assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED");
status.setRollbackOnly();
});
assertThat(state()).isEqualTo("PROCESSING");
assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence
assertThat(begin("b")).isFalse();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1);
}
@Test void startAuditFailureRollsBackTheClaim() {
assertThat(begin("a")).isTrue();
jdbc.update("UPDATE work SET state='PENDING',owner=NULL");
assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PENDING");
}
@Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() {
begin("a");
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PROCESSING");
assertThat(outcome("a")).isEqualTo("STARTED");
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue();
assertThat(state()).isEqualTo("PUBLISHED");
assertThat(outcome("a")).isEqualTo("APPLIED");
assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative();
}
@Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() {
begin("a");
var retryAt = LocalDateTime.now().plusSeconds(5);
assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt,
() -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue();
begin("b");
assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse();
assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED");
assertThat(outcome("b")).isEqualTo("STARTED");
assertThat(state()).isEqualTo("PROCESSING");
}
@Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() {
jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'");
assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class);
assertThat(state()).isEqualTo("PROCESSING");
}
}
@@ -0,0 +1,23 @@
package com.baiyue.common.core;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.outbox.EventOperationsTokenGuard;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class EventOperationsTokenGuardTest {
@Test void missingOrShortConfigurationDisablesOperations() {
for (String configured : new String[] {null, "", "short"}) {
var guard = new EventOperationsTokenGuard(configured);
assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class);
}
}
@Test void onlyExactCredentialIsAccepted() {
String token = "operation-secret-".repeat(3);
var guard = new EventOperationsTokenGuard(token);
assertThatCode(() -> guard.require(token)).doesNotThrowAnyException();
for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) {
assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class);
}
}
}
@@ -0,0 +1,122 @@
package com.baiyue.common.core;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.outbox.OutboxOperations;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Isolated;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.*;
@Isolated("UTC retry regression temporarily changes the JVM default time zone")
class OutboxOperationsTest {
private static final String HASH = "sha256:" + "a".repeat(64);
private JdbcTemplate jdbc;
private OutboxOperations operations;
@BeforeEach void setup() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)");
jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)");
operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source),
new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")),
payload -> new OutboxOperations.Identity(payload, "business.completed", HASH));
jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)");
}
private OutboxOperations.RetryRequest request() {
return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH);
}
@Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() {
var request = request();
assertThat(operations.retry("1", request).duplicate()).isFalse();
assertThat(operations.retry("1", request).duplicate()).isTrue();
assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'"))
.containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING")
.containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null);
assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit"))
.containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout")
.containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH);
}
@Test void auditInsertFailureRollsBackRequeue() {
jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)");
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertThat(operations.inspect("1").status()).isEqualTo("DEAD");
assertThat(operations.inspect("1").failures()).isEqualTo(12);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero();
}
@Test void retryIsImmediatelyDueInUtcUnderShanghaiDefaultZoneAndReplayKeepsTimes() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
var request = request();
LocalDateTime before = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1);
assertThat(operations.retry("1", request).duplicate()).isFalse();
LocalDateTime nextRetry = jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
(row, index) -> row.getObject(1, LocalDateTime.class));
LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
(row, index) -> row.getObject(1, LocalDateTime.class));
LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC);
// Allow database timestamp precision rounding, but never a default-zone offset.
assertThat(nextRetry).isBetween(before, scan.plusSeconds(1));
assertThat(requested).isEqualTo(nextRetry);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM events WHERE id='1' AND status='PENDING' AND next_retry_at<=?",
Integer.class, scan)).isEqualTo(1);
assertThat(operations.retry("1", request).duplicate()).isTrue();
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT next_retry_at FROM events WHERE id='1'",
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(nextRetry);
assertThat(jdbc.<LocalDateTime>queryForObject("SELECT requested_at FROM event_delivery_retry_audit",
(row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT payload_json FROM events WHERE id='1'", String.class)).isEqualTo("event-1");
} finally {
TimeZone.setDefault(original);
}
}
@Test void staleHashAndNonDeadAndReusedRequestAreRejected() {
var original = request();
var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64));
assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class);
operations.retry("1", original);
assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class);
var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH);
assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
}
@Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception {
var request = request();
var start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(2);
try {
var first = pool.submit(() -> { start.await(); return operations.retry("1", request); });
var second = pool.submit(() -> { start.await(); return operations.retry("1", request); });
start.countDown();
assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1);
} finally { pool.shutdownNow(); }
}
@Test void crossEntryRequestCollisionRollsBackSecondEntry() {
jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)");
var request = request();
operations.retry("1", request);
assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class);
assertThat(operations.inspect("2").status()).isEqualTo("DEAD");
}
}
+12 -8
View File
@@ -1,9 +1,14 @@
# Common Event Contract 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。
- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
- 只放事件信封、类型、标识、规范化、哈希和校验;不放部署区域、领域 Entity、数据库模型或发送实现。
## 快速索引
@@ -13,24 +18,23 @@
| 契约校验 | `EventEnvelopeValidator.java` |
| 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 事件类型与直接目标 | `EventTypes.java`、`EventDeliveryRoute.java`;明确白名单,不按区域或默认目标分派 |
| 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 |
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
- 业务事件不携带用户 Access Token;直接投递使用内部服务身份,信封保留事件ID、业务类型/版本、客户、聚合、追踪及Payload哈希,不再包含sourceRegion/targetRegion。
- `OVERSEAS_CONTAINER_UNLOADED` 固定由Lastleg Outbox直投Firstleg,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间规则沿用现业务实现,实际报空时间允许后续补充。
- 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -56,6 +56,7 @@ public record BusinessFeeSourceChangedV1(
String reviewStatus,
String publicationStatus,
String sourceStatus,
String sourceHash
String sourceHash,
WalletSettlementReference walletSettlement
) {
}
@@ -0,0 +1,31 @@
package com.baiyue.common.event;
import java.util.Objects;
/** A consumer returns this only after its durable processing transaction commits. */
public record DeliveryAck(String eventId, String payloadHash, Outcome outcome) {
public enum Outcome { APPLIED, DUPLICATE, STALE }
public DeliveryAck {
if (!EventIdentifiers.isSupported(eventId)) throw new IllegalArgumentException("Invalid ACK eventId");
if (payloadHash == null || !payloadHash.matches("sha256:[0-9a-fA-F]{64}")) {
throw new IllegalArgumentException("Invalid ACK payloadHash");
}
Objects.requireNonNull(outcome, "ACK outcome is required");
}
public static DeliveryAck of(EventEnvelope event, Outcome outcome) {
return new DeliveryAck(event.eventId(), event.payloadHash(), outcome);
}
/** HTTP and ApiResponse status must also be checked by the transport. */
public void requireMatches(EventEnvelope sent) {
if (!eventId.equals(sent.eventId()) || !payloadHash.equalsIgnoreCase(sent.payloadHash())) {
throw new IllegalArgumentException("Consumer ACK does not match the sent event identity and payload");
}
EventDeliveryRoute route = EventDeliveryRoute.forEventType(sent.eventType());
if (outcome == Outcome.STALE && !route.acceptsStale()) {
throw new IllegalArgumentException("STALE is not an allowed result for " + sent.eventType());
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.common.event;
/** Explicit business-event destinations. There is no regional or default destination. */
public record EventDeliveryRoute(String serviceName, String path, boolean acceptsStale) {
public static EventDeliveryRoute forEventType(String eventType) {
if (eventType == null) throw new IllegalArgumentException("Event type is required");
return switch (eventType) {
case EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.WALLET_COMMAND_COMPLETED ->
new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", false);
case EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED ->
new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", true);
case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED ->
new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", false);
case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED ->
new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", true);
case EventTypes.WALLET_COMMAND_REQUESTED ->
new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false);
case EventTypes.BUSINESS_FEE_SOURCE_CHANGED ->
new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false);
case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED ->
new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false);
default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType);
};
}
}
@@ -0,0 +1,7 @@
package com.baiyue.common.event;
/** Required business state has not arrived yet; retry the same event with backoff. */
public class EventDependencyNotReadyException extends IllegalArgumentException {
public EventDependencyNotReadyException(String message) { super(message); }
public EventDependencyNotReadyException(String message, Throwable cause) { super(message, cause); }
}
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import java.time.Instant;
/**
* 跨服务和跨区域传输使用的不可变事件信封。
* 统一测试环境中跨业务服务传输使用的不可变事件信封。
*
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
@@ -14,8 +14,6 @@ import java.time.Instant;
* @param eventType 事件类型,不包含版本号
* @param eventVersion 事件契约版本,从 1 开始
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
* @param sourceRegion 事件来源业务单元
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
* @param tenantId 事件所属客户租户 ID
* @param aggregateType 聚合类型
* @param aggregateId 聚合 ID
@@ -32,8 +30,6 @@ public record EventEnvelope(
String eventType,
int eventVersion,
Instant occurredAt,
RegionCode sourceRegion,
RegionCode targetRegion,
String tenantId,
String aggregateType,
String aggregateId,
@@ -37,12 +37,6 @@ public final class EventEnvelopeValidator {
if (envelope.occurredAt() == null) {
errors.add("occurredAt 不能为空");
}
if (envelope.sourceRegion() == null) {
errors.add("sourceRegion 不能为空");
}
if (envelope.targetRegion() == null) {
errors.add("targetRegion 不能为空");
}
requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
if (envelope.aggregateType() == null
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
@@ -75,7 +69,7 @@ public final class EventEnvelopeValidator {
public static void requireValid(EventEnvelope envelope) {
List<String> errors = validate(envelope);
if (!errors.isEmpty()) {
throw new IllegalArgumentException(String.join(";", errors));
throw new EventProtocolException(String.join(";", errors));
}
}
@@ -0,0 +1,7 @@
package com.baiyue.common.event;
/** An immutable event violates the consumption protocol; retrying unchanged data cannot fix it. */
public class EventProtocolException extends IllegalArgumentException {
public EventProtocolException(String message) { super(message); }
public EventProtocolException(String message, Throwable cause) { super(message, cause); }
}
@@ -16,11 +16,9 @@ public final class EventTypes {
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted";
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
public static final String COMPANY_VEHICLE_COST_SUBMITTED = "CompanyVehicleCostSubmitted";
public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested";
public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
@@ -1,25 +0,0 @@
package com.baiyue.common.event;
import java.time.Instant;
import java.util.Set;
/** 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,
Set<String> roles, Set<String> authorities) {
public PermissionVersionChanged {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
}
}
@@ -1,13 +0,0 @@
package com.baiyue.common.event;
/**
* 事件来源与目标部署区域。
*
* <p>区域表示独立业务单元,不表示用户当前所在地。</p>
*/
public enum RegionCode {
/** 国内业务单元。 */
CN,
/** 海外业务单元。 */
OVERSEAS
}
@@ -15,7 +15,9 @@ public record WalletCommandCompletedV1(
BigDecimal amount,
String errorCode,
String errorMessage,
Instant completedAt
Instant completedAt,
String walletHoldId,
String walletLedgerId
) {
public static final int VERSION = 1;
}
@@ -3,7 +3,13 @@ package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */
/**
* 海外业务向 Finance 发起的客户资金指令。
* FREEZE 的 amount 为预留金额;SETTLE 全额结算;RELEASE 释放原冻结。
* SETTLE_ACTUAL 的 amount 为最终正额 CNY 实扣,原子补差或释放余款,原冻结金额保留。
* 实际零元使用 RELEASE,不发送零元结算。结算和释放按原业务冻结身份关联。
* REFUND 使用独立业务退款号并携带 originalLedgerId,关联原成功消费,不创建充值。
*/
public record WalletCommandRequestedV1(
String operation,
long customerId,
@@ -12,7 +18,8 @@ public record WalletCommandRequestedV1(
BigDecimal amount,
String remark,
String idempotencyKey,
Instant requestedAt
Instant requestedAt,
String originalLedgerId
) {
public static final int VERSION = 1;
}
@@ -0,0 +1,4 @@
package com.baiyue.common.event;
/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { }
@@ -0,0 +1,51 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DeliveryAckTest {
private EventEnvelope event(String type) {
return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), "0", "TEST", "1", 1, null,
null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64));
}
@Test
void roundTripsAndAcceptsOnlyTheSentIdentityAndHash() throws Exception {
var event = event(EventTypes.WALLET_COMMAND_REQUESTED);
var ack = DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED);
var mapper = new ObjectMapper();
assertThat(mapper.readValue(mapper.writeValueAsString(ack), DeliveryAck.class)).isEqualTo(ack);
assertThatCode(() -> ack.requireMatches(event)).doesNotThrowAnyException();
assertThatThrownBy(() -> ack.requireMatches(event(EventTypes.WALLET_COMMAND_REQUESTED))).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new DeliveryAck(event.eventId(), "sha256:" + "b".repeat(64), DeliveryAck.Outcome.APPLIED)
.requireMatches(event)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> mapper.readValue("{\"eventId\":\"" + event.eventId() + "\",\"payloadHash\":\"" + event.payloadHash() + "\",\"outcome\":\"RECEIVED\"}", DeliveryAck.class))
.isInstanceOf(Exception.class);
}
@Test
void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() {
for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED,
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED}) {
var event = event(type);
assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event))
.isInstanceOf(IllegalArgumentException.class);
assertThatCode(() -> DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE).requireMatches(event)).doesNotThrowAnyException();
}
}
@Test
void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() {
assertThat(EventDeliveryRoute.forEventType(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED))
.isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false));
assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED))
.isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false));
assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service");
assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_COMPLETED).serviceName()).isEqualTo("lastleg-service");
assertThatThrownBy(() -> EventDeliveryRoute.forEventType("UnknownEvent")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> EventDeliveryRoute.forEventType("IamUserStatusChanged")).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -13,7 +13,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("跨区域事件信封契约")
@DisplayName("跨服务事件信封契约")
class EventEnvelopeTest {
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
@@ -26,6 +26,8 @@ class EventEnvelopeTest {
EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class);
assertThat(restored).isEqualTo(source);
assertThat(objectMapper.readTree(json).has("sourceRegion")).isFalse();
assertThat(objectMapper.readTree(json).has("targetRegion")).isFalse();
assertThat(objectMapper.readTree(json).path("eventType").asText())
.isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED);
}
@@ -40,7 +42,7 @@ class EventEnvelopeTest {
@Test
void validatorReturnsAllBasicErrorsAtOnce() {
EventEnvelope envelope = new EventEnvelope(
"bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ",
"bad-id", "bad_type", 0, null, " ", "bad-type", " ",
-1, 0L, " ", " ", "bad-cause", null, "bad-hash");
List<String> errors = EventEnvelopeValidator.validate(envelope);
@@ -90,8 +92,6 @@ class EventEnvelopeTest {
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
1,
Instant.parse("2026-07-29T08:30:00Z"),
RegionCode.CN,
RegionCode.OVERSEAS,
"20001",
"FIRST_LEG_SHIPMENT",
"90001",
+6 -1
View File
@@ -1,5 +1,10 @@
# Common OpenAPI 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
@@ -20,6 +25,6 @@
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板;当前正式配置不保留旧配置键兼容。若已有不可变外部配置契约,先单独定义升级规则。
- 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
+11 -5
View File
@@ -1,5 +1,10 @@
# Common Security 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
@@ -10,19 +15,20 @@
| 功能 | 入口 |
|---|---|
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 |
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
## 修改与验证
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
- 所有资源服务只接受 RS256/JWKS,统一 Issuer、Audience 和 `session-mode=redis`。私钥仅在 Auth,业务服务使用 JWKS 公钥缓存与共享 Redis 会话;不再支持 HMAC、claims 或 gateway 会话模式,不信任请求头携带的角色权限。
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。
- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
*/
@AutoConfiguration(
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
after = JwksJwtDecoderAutoConfiguration.class,
before = SecurityAutoConfiguration.class)
@EnableMethodSecurity
public class BaiyueSecurityAutoConfiguration {
@@ -50,16 +50,16 @@ public class BaiyueSecurityAutoConfiguration {
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken);
else {
StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
@Value("${baiyue.security.session-mode:redis}") String sessionMode,
@Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
if (!"redis".equalsIgnoreCase(sessionMode)) {
throw new IllegalStateException("Only shared Redis access sessions are supported");
}
StringRedisTemplate template = redis.getIfAvailable();
if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate");
AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper,
new HttpSessionStateVerifier(validationUri, internalToken, objectMapper));
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
}
@@ -84,7 +84,7 @@ public class BaiyueSecurityAutoConfiguration {
.exceptionHandling(errors -> errors
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/api/pda-updates/**").permitAll()
.requestMatchers("/internal/**").permitAll()
.requestMatchers("/openapi/**").permitAll()
.requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT")
@@ -1,16 +0,0 @@
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; }
}
@@ -1,65 +0,0 @@
package com.baiyue.common.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.LinkedHashSet;
import java.util.Set;
/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */
public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator {
public static final String TOKEN_HEADER = "X-Internal-Service-Token";
public static final String ROLES_HEADER = "X-Overseas-Roles";
public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities";
private final byte[] expectedToken;
public GatewayAccessTokenSessionValidator(String expectedToken) {
if (expectedToken == null || expectedToken.isBlank()) {
throw new IllegalStateException("session-mode=gateway requires an internal service token");
}
this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8);
}
@Override
public AccessTokenSession validate(Jwt jwt) {
throw new IllegalArgumentException("Gateway authorization headers are required");
}
@Override
public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) {
String supplied = request.getHeader(TOKEN_HEADER);
if (supplied == null || supplied.isBlank()) {
throw new IllegalArgumentException("Missing overseas gateway authorization context");
}
byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, suppliedToken)) {
throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes="
+ expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")");
}
Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id")));
if (userId == null) {
userId = number(jwt.getSubject());
}
return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)),
values(request.getHeader(AUTHORITIES_HEADER)));
}
private static Long number(Object value) {
if (value instanceof Number number) return number.longValue();
try { return value == null ? null : Long.valueOf(value.toString()); }
catch (NumberFormatException exception) { return null; }
}
private static Set<String> values(String header) {
if (header == null || header.isBlank()) return Set.of();
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String value : header.split(",")) {
String normalized = value.trim();
if (!normalized.isEmpty()) values.add(normalized);
}
return Set.copyOf(values);
}
}
@@ -1,33 +0,0 @@
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.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
/**
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
*/
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
public class HmacJwtDecoderAutoConfiguration {
@Bean
@ConditionalOnMissingBean(JwtDecoder.class)
JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret,
@Value("${baiyue.security.issuer:auth-service}") String issuer) {
byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256"))
.macAlgorithm(MacAlgorithm.HS256).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));
return decoder;
}
}
@@ -0,0 +1,44 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */
public final class HttpSessionStateVerifier implements SessionStateVerifier {
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1))
.followRedirects(HttpClient.Redirect.NEVER).build();
private final URI uri;
private final String token;
private final ObjectMapper mapper;
public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) {
this.uri = URI.create(uri);
this.token = token;
this.mapper = mapper;
}
@Override public void verify(String jwtId, Long userId) {
try {
var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4))
.header("Content-Type", "application/json").header("X-Internal-Service-Token", token)
.POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(
Map.of("jwtId", jwtId, "userId", userId)))).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var body = mapper.readTree(response.body());
if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText())
|| !body.path("data").isBoolean() || !body.path("data").booleanValue()) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalArgumentException("Authorization state validation interrupted", failure);
} catch (Exception failure) {
throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure);
}
}
}
@@ -3,7 +3,6 @@ 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;
@@ -14,14 +13,14 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import java.util.List;
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
/** 所有业务服务通过可轮换 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,
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri,
@Value("${baiyue.security.issuer:auth-service}") String issuer,
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
// Fetch and cache JWKS on token verification, so startup does not depend on Auth availability.
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
@@ -28,10 +28,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final AccessTokenSessionValidator sessionValidator;
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
AuthenticationEntryPoint authenticationEntryPoint) {
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null);
}
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
AuthenticationEntryPoint authenticationEntryPoint,
@@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
private final String keyPrefix;
private final ObjectMapper objectMapper;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
this(redisTemplate, keyPrefix, new ObjectMapper());
}
private final SessionStateVerifier stateVerifier;
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
ObjectMapper objectMapper) {
ObjectMapper objectMapper, SessionStateVerifier stateVerifier) {
this.redisTemplate = redisTemplate;
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
this.objectMapper = objectMapper;
this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier);
}
@Override
@@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio
if (userId == null || !userId.equals(session.userId())) {
throw new IllegalArgumentException("Access token session identity mismatch");
}
stateVerifier.verify(jwtId, userId);
return session;
} catch (org.springframework.dao.DataAccessException exception) {
throw new IllegalArgumentException("Token session store is unavailable", exception);
@@ -0,0 +1,7 @@
package com.baiyue.common.security;
/** Verifies persisted authorization state, without positive caching. */
@FunctionalInterface
public interface SessionStateVerifier {
void verify(String jwtId, Long userId);
}
@@ -1,3 +1,2 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
@@ -1,49 +0,0 @@
package com.baiyue.common.security;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GatewayAccessTokenSessionValidatorTest {
private final GatewayAccessTokenSessionValidator validator =
new GatewayAccessTokenSessionValidator("gateway-secret");
@Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view");
AccessTokenSession session = validator.validate(jwt(), request);
assertThat(session.userId()).isEqualTo(42L);
assertThat(session.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(session.permissions()).containsExactlyInAnyOrder(
"platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view");
}
@Test void rejectsForgedOrMissingGatewayToken() {
MockHttpServletRequest forged = new MockHttpServletRequest();
forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged");
forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER,
"platform:overseas-warehouse:last-mile-order:list");
assertThatThrownBy(() -> validator.validate(jwt(), forged))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway");
assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway");
}
private Jwt jwt() {
return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permission_version", 7L).claim("token_version", 1).build();
}
}
@@ -0,0 +1,45 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
class HttpSessionStateVerifierTest {
@Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception {
var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}");
var auth = new AtomicReference<String>();
var internal = new AtomicReference<String>();
var input = new AtomicReference<String>();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/validate", exchange -> {
auth.set(exchange.getRequestHeaders().getFirst("Authorization"));
internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
});
server.start();
try {
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper());
verifier.verify("session", 42L);
assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only");
assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42);
for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}",
"{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) {
body.set(rejected);
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
} finally { server.stop(0); }
}
@Test void unavailableAuthorityFailsClosed() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
int port = server.getAddress().getPort(); server.start(); server.stop(0);
var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper());
assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,94 @@
package com.baiyue.common.security;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JwksJwtDecoderAutoConfigurationTest {
@Test
void createsDecoderWhenAuthIsUnavailableButRejectsTokenVerification() throws Exception {
int port;
try (var socket = new ServerSocket(0)) {
port = socket.getLocalPort();
}
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
"http://127.0.0.1:" + port + "/jwks", "auth-service", "lastleg-api");
var key = new RSAKeyGenerator(2048).keyID("current").generate();
String token = token(key, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
assertThatThrownBy(() -> decoder.decode(token)).isInstanceOf(JwtException.class);
}
@Test
void fetchesOnFirstVerificationCachesKeysAndRefreshesForRotation() throws Exception {
var current = new RSAKeyGenerator(2048).keyID("current").generate();
var next = new RSAKeyGenerator(2048).keyID("next").generate();
var published = new AtomicReference<>(new JWKSet(current.toPublicJWK()));
var requests = new AtomicInteger();
var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/jwks", exchange -> {
requests.incrementAndGet();
byte[] body = published.get().toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
try (var output = exchange.getResponseBody()) {
output.write(body);
}
});
server.start();
try {
var decoder = new JwksJwtDecoderAutoConfiguration().jwksJwtDecoder(
"http://127.0.0.1:" + server.getAddress().getPort() + "/jwks",
"auth-service", "lastleg-api");
assertThat(requests.get()).isZero();
String valid = token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS256);
assertThat(decoder.decode(valid).getSubject()).isEqualTo("42");
assertThatCode(() -> decoder.decode(valid)).doesNotThrowAnyException();
assertThat(requests.get()).isEqualTo(1);
for (String invalid : new String[]{
token(current, "other", "lastleg-api", 120, JWSAlgorithm.RS256),
token(current, "auth-service", "other", 120, JWSAlgorithm.RS256),
token(current, "auth-service", "lastleg-api", -120, JWSAlgorithm.RS256),
token(current, "auth-service", "lastleg-api", 120, JWSAlgorithm.RS512)}) {
assertThatThrownBy(() -> decoder.decode(invalid)).isInstanceOf(JwtException.class);
}
published.set(new JWKSet(next.toPublicJWK()));
assertThat(decoder.decode(token(next, "auth-service", "lastleg-api", 120,
JWSAlgorithm.RS256)).getSubject()).isEqualTo("42");
assertThat(requests.get()).isEqualTo(2);
} finally {
server.stop(0);
}
}
private String token(RSAKey key, String issuer, String audience, int ttl, JWSAlgorithm algorithm)
throws Exception {
var jwt = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(key.getKeyID()).build(),
new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience)
.issueTime(Date.from(Instant.now().minusSeconds(300)))
.expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build());
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
}
@@ -29,7 +29,8 @@ class JwtAuthenticationFilterTest {
.claim("user_id", 1L).build();
JwtDecoder decoder = token -> jwt;
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(),
new JsonAuthenticationEntryPoint(new ObjectMapper()));
new JsonAuthenticationEntryPoint(new ObjectMapper()),
token -> new AccessTokenSession(1, 1L, java.util.Set.of(), java.util.Set.of()));
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid");
MockHttpServletResponse response = new MockHttpServletResponse();
@@ -43,20 +44,25 @@ class JwtAuthenticationFilterTest {
}
@Test
void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception {
void authenticatesUnifiedTokenWithServerSessionIgnoringGatewayHeaders() throws Exception {
Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42")
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
.claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("jti", "session-42").claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
.claim("permission_version", 7L).claim("token_version", 1).build();
var redis = org.mockito.Mockito.mock(org.springframework.data.redis.core.StringRedisTemplate.class);
var values = org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class);
org.mockito.Mockito.when(redis.opsForValue()).thenReturn(values);
org.mockito.Mockito.when(values.get("baiyue:auth:access:session-42"))
.thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}");
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(),
new JsonAuthenticationEntryPoint(new ObjectMapper()),
new GatewayAccessTokenSessionValidator("gateway-secret"));
new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class)));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestURI("/api/platform/v1/last-leg/orders");
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas");
request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret");
request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN");
request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view");
request.addHeader("X-Internal-Service-Token", "gateway-secret");
request.addHeader("X-Overseas-Roles", "FORGED_ADMIN");
request.addHeader("X-Overseas-Authorities", "platform:wallet:write");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicBoolean invoked = new AtomicBoolean();
@@ -64,7 +70,8 @@ class JwtAuthenticationFilterTest {
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertThat(authentication).isNotNull();
assertThat(authentication.getAuthorities()).extracting("authority")
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view");
.contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view")
.doesNotContain("ROLE_FORGED_ADMIN", "platform:wallet:write");
invoked.set(true);
});
@@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest {
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", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1"))).doesNotThrowAnyException();
}
@@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest {
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", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("revoked or expired");
@@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1"))
.thenThrow(new RedisConnectionFailureException("offline"));
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unavailable");
@@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest {
when(values.get("baiyue:auth:access:jti-1"))
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("identity mismatch");
@@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest {
when(redis.opsForValue()).thenReturn(values);
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class))
.validate(jwt("jti-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid access token session");
@@ -0,0 +1,22 @@
package com.baiyue.common.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UnifiedSessionModeTest {
@Test
void rejectsLegacySessionModesAndMissingRedisInsteadOfTrustingClaimsOrHeaders() {
var configuration = new BaiyueSecurityAutoConfiguration();
var beans = new StaticListableBeanFactory();
var redis = beans.getBeanProvider(StringRedisTemplate.class);
for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) {
assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null,
new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()),
redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token"))
.isInstanceOf(IllegalStateException.class);
}
}
}
+9 -2
View File
@@ -1,5 +1,10 @@
# Common Web 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
@@ -13,6 +18,8 @@
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
| 可靠事件 HTTP 客户端配置 | `EventDeliveryFeignConfiguration.java`;由业务事件 Feign 客户端显式选择,在最终构建阶段固定连接/读取超时、禁止重定向和客户端内部重试,不作为全局自动配置 |
| 投递审计和指标装配 | `EventDeliveryObservationAutoConfiguration.java`、`EventDeliveryMetrics.java`、`EventDeliveryObservation.java`;仅EventDeliveryProducer标记的业务Relay装配必需Journal/Metrics,其他JDBC服务不装配;定时采样固定事件白名单,Prometheus仅读内存快照,采样失败保留旧值并暴露失败/时间,错误标签不含载荷或令牌 |
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
@@ -23,8 +30,8 @@
## 修改与验证
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503 与 Audit 202 仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时验证 `audit-service` 等当前构建内、只扫描自身包的直接消费者仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`;按根 POM 与实际依赖限定范围。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。

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