Compare commits

...
Author SHA1 Message Date
ouyangxq a8037fd97d Fix native dispatch warehouse text projection and TEST receipt setup 2026-10-20 09:16:16 +08:00
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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/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
liuyihe 29f05b7b09 贴标申请,仓储转运 2026-09-17 17:37:29 +08:00
admin e7436263d7 Merge pull request 'ci: remove duplicate production deploy approval' (#4) from test into main
Reviewed-on: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/4
2026-09-17 16:02:46 +08:00
ouyangxq 3b8b6f5aa2 ci: remove duplicate production deploy approval 2026-09-17 16:01:00 +08:00
admin 662667aba3 Merge pull request 'Test' (#3) from test into main
Reviewed-on: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/3
2026-09-17 15:27:43 +08:00
ouyangxq 9dc344bdef test(sql): align contracts with clean initialization 2026-09-17 15:10:27 +08:00
ouyangxq 1fe99eb6d0 fix(iam): grant merchant return inventory permission 2026-09-17 15:10:27 +08:00
wujinliang 522afd0185 1 2026-09-17 15:05:02 +08:00
wujinliang 66c68cdc89 1 2026-09-17 14:40:02 +08:00
admin e147bec604 Merge pull request 'test(auth): align SQL contracts with clean initialization' (#2) from test into main
Reviewed-on: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/2
2026-09-17 14:00:50 +08:00
ouyangxq 6ffd56a0a3 test(auth): align SQL contracts with clean initialization 2026-09-17 13:59:31 +08:00
admin eed95a384a Merge pull request 'Test' (#1) from test into main
Reviewed-on: https://git.buggoglobal.com/java-project/baiyue-firstleg/pulls/1
2026-09-17 11:52:43 +08:00
ouyangxq cd16687d25 ci: 收口生产 main 分支部署流程 2026-09-17 11:49:00 +08:00
ouyangxq bf8256a817 test: 标记物理箱全链路验收完成 2026-09-17 10:21:46 +08:00
ouyangxq 9ab1c328ba fix: 支持多SKU共箱整托装柜 2026-09-16 21:22:18 +08:00
ouyangxq 1cd10a9d3b test: 对齐海外空库初始化合同 2026-09-16 21:06:00 +08:00
ouyangxq c6797a8aad fix: 恢复海外服务 Nacos 凭据注入 2026-09-16 20:58:52 +08:00
ouyangxq 7ce662949b 优化 2026-09-16 20:50:10 +08:00
ouyangxq 545ad2083d fix: 按物理箱贯通一件代发履约箱数 2026-09-16 20:49:35 +08:00
ouyangxq 8797c0bef9 优化 2026-09-16 19:32:52 +08:00
ouyangxq dfcd280dd7 优化 2026-09-16 17:52:02 +08:00
ouyangxq 053e59bec1 优化 2026-09-16 17:49:27 +08:00
ouyangxq 8488d4d989 优化 2026-09-16 17:49:20 +08:00
ouyangxq 392f5b7386 优化 2026-09-16 17:15:18 +08:00
ouyangxq 854d484142 优化 2026-09-16 16:42:05 +08:00
ouyangxq b79cd9ec42 优化 2026-09-16 16:17:38 +08:00
ouyangxq 4571e3ac16 优化 2026-09-16 15:57:37 +08:00
ouyangxq 8f0622058d 优化 2026-09-16 15:21:08 +08:00
ouyangxq 912feca56f 优化 2026-09-16 12:02:38 +08:00
ouyangxq de29e639e4 fix: check released bookings only for active loading allocations 2026-09-16 11:50:30 +08:00
ouyangxq 35c6c6ec03 优化 2026-09-16 11:43:02 +08:00
ouyangxq dcd8cc2c85 test: cover loading reassignment and PDA completion gates 2026-09-16 11:40:57 +08:00
ouyangxq 011aa80870 fix: ignore empty loading links during completion 2026-09-16 11:33:16 +08:00
ouyangxq a975b07968 优化 2026-09-16 11:14:32 +08:00
ouyangxq a098c4377c 优化 2026-09-16 11:06:33 +08:00
ouyangxq a4c659f4c6 fix: count distinct SKU codes across shared pallet inbounds 2026-09-16 10:18:56 +08:00
ouyangxq c099b19176 完善一件代发整托拣货回归 2026-09-15 21:05:08 +08:00
ouyangxq 8b19085bc4 优化 2026-09-15 20:52:02 +08:00
ouyangxq 4b671bb0ab 优化 2026-09-15 18:17:06 +08:00
ouyangxq 45018987ef fix(return): replay concurrent merges and extend integrity acceptance 2026-09-15 18:00:08 +08:00
ouyangxq 6b0de57a4b test(return): cover isolation and concurrency; fix receipt and packing races 2026-09-15 17:05:07 +08:00
ouyangxq 28fa2a001d 优化 2026-09-15 16:39:44 +08:00
ouyangxq c207a4d3b7 优化 2026-09-15 16:10:55 +08:00
liuyihe 506525524b Merge remote-tracking branch 'origin/test' into test 2026-09-15 15:28:40 +08:00
ouyangxq fd86c1b801 优化 2026-09-15 15:25:05 +08:00
liuyihe cadc5a175e 提交 2026-09-15 15:15:46 +08:00
ouyangxq 6d5cc03741 优化 2026-09-15 15:13:15 +08:00
ouyangxq 7edea222d8 test: synchronize initialization and dependency contracts 2026-09-15 14:58:26 +08:00
ouyangxq c65389b2c2 fix: preserve receiving session on duplicate pallet scans 2026-09-15 14:44:10 +08:00
ouyangxq 791918c0dc 修正排车路线字段合同 2026-09-15 14:36:43 +08:00
ouyangxq d1a00b8e01 优化 2026-09-15 14:30:47 +08:00
ouyangxq a474ca4d93 优化 2026-09-15 14:18:18 +08:00
ouyangxq 6a2075fe4b 优化 2026-09-15 13:43:12 +08:00
ouyangxq f9649c44e9 优化 2026-09-15 13:36:58 +08:00
ouyangxq 1bb69698de fix: expose completed unified order status 2026-09-15 13:12:17 +08:00
ouyangxq 960b5d6b0a test: retain a second FBA fixture for delivery split 2026-09-15 13:01:44 +08:00
ouyangxq 9914d40593 test: prove dispatch split across FBA destinations 2026-09-15 12:54:09 +08:00
ouyangxq a7a6d78568 fix: persist overseas delivery completion history 2026-09-15 12:47:45 +08:00
ouyangxq 13ff7e1513 test: use supported delivery vehicle size 2026-09-15 12:34:47 +08:00
ouyangxq 2c183f0def test: use supported dispatch vehicle capacity 2026-09-15 12:29:47 +08:00
ouyangxq 9e53a0d11f test: use channel groups in booking fixtures 2026-09-15 12:24:28 +08:00
ouyangxq 9888072d8c fix: limit dispatch vehicles to 15 and 30 pallets 2026-09-15 12:17:16 +08:00
ouyangxq 37c3e676a4 test: remove manual delivery completion permission contract 2026-09-15 12:12:57 +08:00
ouyangxq b2c5bafb8e fix: remove unused pallet carton mark override 2026-09-15 12:08:11 +08:00
ouyangxq a400563552 test: honor runtime inbound code policy in HTTP order fixtures 2026-09-15 12:07:43 +08:00
ouyangxq 6e35993e61 更新派送自动完成测试验收 2026-09-15 12:07:10 +08:00
ouyangxq c1f01e1e87 优化 2026-09-15 11:54:14 +08:00
ouyangxq 3fb9909dd3 test(lastleg): align relabel upload fixture and PDA status comment 2026-09-15 11:45:40 +08:00
ouyangxq 3ebe254c45 fix(dropship): serialize outbound creation and pick exact carton subsets 2026-09-15 11:42:19 +08:00
ouyangxq 88e2fb0eb1 优化 2026-09-15 10:37:34 +08:00
liuyihe 419950482f Merge remote-tracking branch 'origin/test' into test 2026-09-14 22:44:54 +08:00
ouyangxq 4f13ee2ec3 fix(dropship): preserve carton quantities and shared pallet outbound ownership 2026-09-14 20:55:09 +08:00
liuyihe 70145e6b3b 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 20:21:07 +08:00
ouyangxq 6dbcb2128c 优化 2026-09-14 20:20:57 +08:00
ouyangxq ebc299d58a 优化 2026-09-14 20:03:22 +08:00
ouyangxq d53de922db 优化 2026-09-14 18:34:37 +08:00
ouyangxq d9a4384585 fix(firstleg): align measurement package validation 2026-09-14 18:32:32 +08:00
ouyangxq 3910b8fcce feat: finalize dropship pallet print documents 2026-09-14 17:52:01 +08:00
ouyangxq b3d74c5441 test: align full-chain fixture invariants 2026-09-14 17:45:56 +08:00
ouyangxq e26b39ba0a test: use current loading remarks endpoint 2026-09-14 17:36:52 +08:00
ouyangxq a9959620f1 test: target unified order remarks endpoint 2026-09-14 17:31:06 +08:00
ouyangxq 6f917f9b2a Merge remote-tracking branch 'origin/test' into test 2026-09-14 17:29:27 +08:00
ouyangxq 4c048d0760 test: use current order remarks contract 2026-09-14 17:29:03 +08:00
liuyihe dbc4926d14 Merge remote-tracking branch 'origin/test' into test 2026-09-14 17:26:01 +08:00
ouyangxq 64bddb1f81 fix: scope smart loading recommendations 2026-09-14 17:22:09 +08:00
ouyangxq 8a1e587ee4 fix(order): align FBA submit and fee confirmation 2026-09-14 17:19:06 +08:00
liuyihe 57358d7665 Merge branch 'test' into dev 2026-09-14 17:04:24 +08:00
ouyangxq e0af45db02 feat: add editable order collaboration remarks 2026-09-14 16:53:09 +08:00
liuyihe 57f62ae97b 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 16:49:08 +08:00
liuyihe 78bbd69a26 废弃 ,换标文件上传 ,排车托盘锁粒度 2026-09-14 16:49:00 +08:00
ouyangxq 26c37deb55 fix: enforce dropship pallet count for inbound plans 2026-09-14 16:38:50 +08:00
ouyangxq 99f2c730e0 优化 2026-09-14 15:53:49 +08:00
ouyangxq f9fa859391 优化 2026-09-14 15:51:52 +08:00
ouyangxq 13d2fe8e3a fix: merge multi-pallet dropship handover fragments 2026-09-14 15:44:51 +08:00
ouyangxq fe888f9278 fix(iam): scope customer nickname to customer management 2026-09-14 15:38:29 +08:00
ouyangxq 059757507a 优化 2026-09-14 15:33:45 +08:00
ouyangxq cdac78e4f7 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-14 15:33:30 +08:00
ouyangxq 46b4d14898 优化 2026-09-14 15:33:29 +08:00
liuyihe cd578a8615 优化提交 2026-09-14 15:26:00 +08:00
liuyihe 60ebc2e376 优化提交 2026-09-14 15:03:04 +08:00
ouyangxq febe265c50 fix: tolerate stale pallets in smart recommendation 2026-09-14 15:01:05 +08:00
ouyangxq 1d258ab27b fix: keep dropship pallets atomic in smart loading 2026-09-14 14:35:29 +08:00
ouyangxq de9516d19e test: align client registration replay with current auth contract 2026-09-14 13:49:35 +08:00
ouyangxq d8254c4f1d 优化 2026-09-14 13:48:21 +08:00
ouyangxq d8e2dc59ae fix: validate transfer rejection and align dispatch HTTP contracts 2026-09-14 13:46:32 +08:00
ouyangxq a2ea0ba9e6 fix: align HTTP acceptance fixtures and pallet permissions 2026-09-14 12:40:40 +08:00
ouyangxq e55ab73e61 优化 2026-09-14 12:00:48 +08:00
ouyangxq fb66d9200a fix: give warehouse transfer approval a distinct permission id 2026-09-14 11:35:14 +08:00
ouyangxq 54f5bdfb1f fix: align overseas administrator authority assertion with merged baseline 2026-09-14 11:26:16 +08:00
ouyangxq 035537e5cc 优化 2026-09-14 11:12:29 +08:00
ouyangxq 481ab348ff Merge latest origin/dev into test retaining verified acceptance workflows 2026-09-14 11:05:33 +08:00
ouyangxq d4fec8294d Merge origin/dev into test preserving warehouse and PDA workflows 2026-09-14 11:03:47 +08:00
liuyihe 6c7bc1dc97 Merge test into dev and resolve conflicts 2026-09-14 11:02:37 +08:00
liuyihe f7ef8be18f 优化提交 2026-09-14 10:37:23 +08:00
liuyihe 8cf7c4b9c9 优化提交 2026-09-14 09:58:30 +08:00
ouyangxq cc2aed72d9 优化 2026-09-12 18:34:23 +08:00
ouyangxq f26e4b2972 fix(lastleg): trace return carton sources and generate scannable labels 2026-09-12 16:16:38 +08:00
ouyangxq e467d60e8f fix(lastleg): allow edits with historical procurement warehouse 2026-09-12 16:14:50 +08:00
ouyangxq d33922ad35 fix overseas return inventory flow 2026-09-12 13:55:48 +08:00
ouyangxq 2191bacbfe fix qualification upload contract and test baseline 2026-09-12 12:27:56 +08:00
ouyangxq dcd079b5b7 优化 2026-09-11 18:43:57 +08:00
ouyangxq 06b9f3eda7 fix: persist PDA picking progress incrementally 2026-09-11 18:24:18 +08:00
ouyangxq 019db2900a 优化 2026-09-11 18:14:29 +08:00
ouyangxq 30d6896c8a docs: clarify scan-first pallet workflow 2026-09-11 17:29:05 +08:00
ouyangxq 63ce4c48a7 优化 2026-09-11 17:27:27 +08:00
ouyangxq 458a4cfa50 优化 2026-09-11 17:20:56 +08:00
ouyangxq c8e8245853 feat: allow shared dropship pallets 2026-09-11 17:12:03 +08:00
ouyangxq 1683977e0b 优化 2026-09-11 17:01:56 +08:00
ouyangxq b6c66565b9 fix: unify same-carton order metrics 2026-09-11 16:50:10 +08:00
ouyangxq 87ae82a114 test: support manual dropship count fixtures 2026-09-11 16:34:40 +08:00
ouyangxq 010475a4c3 fix: prepare mixed-SKU dropship acceptance data 2026-09-11 16:21:14 +08:00
ouyangxq 51ce609ab1 优化 2026-09-11 16:17:16 +08:00
ouyangxq 2d928d667b fix: resolve cross-plan pallet allocations 2026-09-11 16:10:49 +08:00
ouyangxq 64fa620c19 test: add full return receipt acceptance 2026-09-11 16:02:10 +08:00
ouyangxq e1319cb389 fix: keep unified SQL pass marker final 2026-09-11 15:50:17 +08:00
ouyangxq 071f807b87 fix: aggregate mixed cartons by fulfillment type 2026-09-11 15:45:05 +08:00
ouyangxq 774f0472e8 feat: explain supplemental pallet counts 2026-09-11 15:39:38 +08:00
ouyangxq cc7c2f0954 feat: support mixed-SKU dropship cartons 2026-09-11 15:38:19 +08:00
ouyangxq 0db11517db fix: persist return forecast tail references 2026-09-11 15:10:01 +08:00
ouyangxq 183b242d45 test: cover PDA return receipt lifecycle 2026-09-11 15:01:17 +08:00
ouyangxq 3b97eedc3d fix: block dropship work until pallets are ready 2026-09-11 13:54:06 +08:00
ouyangxq 137f8173ab fix: keep full-chain acceptance aligned with current APIs 2026-09-11 12:36:12 +08:00
ouyangxq 1f20516d23 docs: update dropship pallet acceptance flow 2026-09-11 12:27:41 +08:00
ouyangxq b57ca7def7 fix: align dropship acceptance with precreated pallets 2026-09-11 12:26:52 +08:00
ouyangxq e61050c490 feat: precreate dropship pallet codes after measurement 2026-09-11 12:12:41 +08:00
ouyangxq 69076ea712 fix: print overseas warehouse address on BOL 2026-09-11 11:55:46 +08:00
ouyangxq 749d403ed4 优化 2026-09-11 11:52:41 +08:00
ouyangxq d2cff49b9e 优化 2026-09-11 11:51:26 +08:00
ouyangxq d60689ce02 feat: match warehouse BOL print layout 2026-09-11 11:35:37 +08:00
ouyangxq 7fd2e70024 优化 2026-09-11 11:15:01 +08:00
ouyangxq a4b7247c57 fix: expose overseas receiving business facts 2026-09-11 10:32:30 +08:00
ouyangxq b2f85501ac fix: connect overseas lastleg to print service 2026-09-11 10:24:08 +08:00
ouyangxq 220d159cec feat: generate dispatch BOL PDFs 2026-09-11 10:09:06 +08:00
ouyangxq 81f4f9163c fix: link terminal drayage carrier events 2026-09-11 02:20:50 +08:00
ouyangxq fe278f2ba6 test: sequence terminal drayage around unloading 2026-09-11 01:50:46 +08:00
ouyangxq abbacc683e test: align acceptance and dependency contracts 2026-09-11 01:31:14 +08:00
ouyangxq 543ebee5cc test: refresh merchant token before PDA handoff 2026-09-11 01:24:00 +08:00
ouyangxq 4f27e33daf test: align booking channel group contract 2026-09-11 01:20:21 +08:00
ouyangxq 3209b2e647 test: follow complete relabel lifecycle 2026-09-11 01:18:01 +08:00
ouyangxq 7dff9b57ed fix: reuse overseas receiving resources in acceptance 2026-09-11 00:48:21 +08:00
ouyangxq 9abb0004dd test: align overseas PDA group receipt semantics 2026-09-11 00:24:35 +08:00
ouyangxq 2bd12f8a5f fix: converge inbound pallets and retries 2026-09-11 00:06:57 +08:00
ouyangxq ed12769858 fix: converge dropship manifest schema 2026-09-10 23:48:48 +08:00
ouyangxq 4294a5c3db 优化 2026-09-10 23:27:49 +08:00
ouyangxq 89573fd1f0 fix: make unified database migrations converge 2026-09-10 23:12:37 +08:00
ouyangxq 56635ad4e4 feat: complete finance and overseas receiving workflow 2026-09-10 22:43:11 +08:00
ouyangxq e531ae9407 fix(acceptance): require FBA pallet label configuration 2026-09-10 21:53:15 +08:00
ouyangxq 6fa2260572 fix(acceptance): derive split assertions from real cargo 2026-09-10 21:29:40 +08:00
ouyangxq e3dc5329aa fix(acceptance): reuse configured merchant credentials 2026-09-10 21:26:52 +08:00
ouyangxq 6cf144e53d feat: expose dispatch BOL lines 2026-09-10 21:22:06 +08:00
ouyangxq 800ebb875f fix(acceptance): enforce immutable confirmed fees 2026-09-10 21:18:52 +08:00
ouyangxq 7b35373da3 fix(firstleg): enforce PDA task warehouse scope 2026-09-10 20:47:23 +08:00
ouyangxq 47a70406f6 fix(acceptance): reuse initialized pallet fixtures 2026-09-10 20:47:15 +08:00
ouyangxq 69e80a736e fix(sql): make domestic migration replay-safe 2026-09-10 20:19:11 +08:00
ouyangxq 2b73245c96 test(firstleg): align workbench channel group contract 2026-09-10 19:38:40 +08:00
ouyangxq 07b5f6e077 test(iam): align migration guide contract 2026-09-10 19:34:40 +08:00
ouyangxq c480f957c5 优化 2026-09-10 19:27:09 +08:00
ouyangxq 155dd9d77d feat(firstleg): align simulated containers with channel groups 2026-09-10 19:24:49 +08:00
ouyangxq 5ffd6c0118 fix: require configured overseas service tokens 2026-09-10 18:22:42 +08:00
ouyangxq 959d43e308 Merge remote-tracking branch 'origin/dev' into dev 2026-09-10 18:19:10 +08:00
ouyangxq 2fc2fca9fd Merge test fixes into dev dropship flow 2026-09-10 18:18:41 +08:00
liuyihe 72e62c0532 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
#	lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
#	scripts/README.md
2026-09-10 17:25:07 +08:00
ouyangxq 56d555668d 优化 2026-09-10 17:13:02 +08:00
liuyihe 2e6a18c58e 优化 2026-09-10 17:11:33 +08:00
ouyangxq d37e6cf147 fix: auto materialize completed container plans 2026-09-10 17:05:40 +08:00
ouyangxq 343c6e278a fix: block receiving before pallet materialization 2026-09-10 16:43:08 +08:00
ouyangxq 1ce99e710d fix: materialize signed manual pallet plans 2026-09-10 16:33:32 +08:00
ouyangxq f5d6c37080 fix: deduplicate phase5 overseas schema baseline 2026-09-10 15:49:20 +08:00
ouyangxq f0b8514890 fix: materialize container pallet plans 2026-09-10 15:40:55 +08:00
ouyangxq a5db6fbbb6 优化 2026-09-10 15:14:30 +08:00
ouyangxq 2963acc32b fix(lastleg): return empty container customer options 2026-09-10 14:41:03 +08:00
ouyangxq bf7579f93b 优化 2026-09-10 14:38:22 +08:00
ouyangxq 6d0cd3e301 优化 2026-09-10 14:16:24 +08:00
ouyangxq 0dc0053787 优化 2026-09-10 14:15:41 +08:00
ouyangxq 3e4d6b76aa fix(events): allow container nodes before ocean booking 2026-09-10 14:10:02 +08:00
ouyangxq c026edbb2f fix(firstleg): allow customs view for empty loading plan 2026-09-10 14:02:26 +08:00
ouyangxq a3890e7a55 fix(events): allow manual nodes without tracking query 2026-09-10 13:56:07 +08:00
ouyangxq c051be3a67 fix(gateway): route container shipping nodes 2026-09-10 13:45:41 +08:00
ouyangxq 98f61ba3ed 优化 2026-09-10 12:18:33 +08:00
ouyangxq 443b01c8a8 fix(firstleg): decouple manual container nodes from tracking 2026-09-10 12:17:45 +08:00
ouyangxq c1a36c6f40 fix(firstleg): enable manual nodes for consolidated containers 2026-09-10 11:55:09 +08:00
ouyangxq 2821c5e9c7 fix(firstleg): update submitted outer dimensions safely 2026-09-10 11:42:05 +08:00
ouyangxq e8890389ff fix(firstleg): keep PDA photos accessible after departure 2026-09-10 11:37:51 +08:00
ouyangxq 1ef289cd09 优化 2026-09-10 11:25:16 +08:00
ouyangxq e9aa2ac047 优化 2026-09-10 11:18:31 +08:00
ouyangxq 8731efcbea fix(firstleg): allow fee revision until final fulfillment lock 2026-09-10 11:02:45 +08:00
ouyangxq a926a23b87 fix: allow overseas container detail before carrier tracking 2026-09-10 10:59:09 +08:00
ouyangxq 630150a7c7 fix: allow unassigned overseas receiving warehouse 2026-09-10 10:34:24 +08:00
ouyangxq e72e74b21e fix: stabilize overseas initialization and PDA acceptance 2026-09-10 10:25:16 +08:00
ouyangxq 37a28b9c11 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/resources/db/lastleg.sql
2026-09-10 10:01:35 +08:00
ouyangxq 87e65043f3 fix: treat overseas unload photos as platform evidence 2026-09-10 00:18:17 +08:00
ouyangxq 3e4b9d0a9a 优化 2026-09-09 23:46:21 +08:00
ouyangxq 8487eeb1b0 merge: sync latest test environment changes 2026-09-09 22:45:42 +08:00
ouyangxq 107374bec5 fix: align overseas container scope migration 2026-09-09 21:37:01 +08:00
ouyangxq a05b263ca8 fix: support multi-customer overseas receiving containers 2026-09-09 21:30:39 +08:00
ouyangxq c8ab4d3de5 merge: sync test and stabilize full acceptance 2026-09-09 21:14:25 +08:00
ouyangxq 6737c4c327 优化 2026-09-09 20:53:24 +08:00
ouyangxq eac6875877 test: stabilize full dev acceptance orchestration 2026-09-09 20:36:53 +08:00
ouyangxq 71604c9c39 fix: deliver actual destuff pallet fees 2026-09-09 20:00:44 +08:00
ouyangxq b3e0ea9199 fix: complete dropship container after pallet receipt 2026-09-09 19:41:29 +08:00
ouyangxq f3bbe0e15f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 19:40:30 +08:00
ouyangxq 5ccce6af71 test: carry sealed pallet into overseas acceptance 2026-09-09 19:25:13 +08:00
ouyangxq 4cc3e5372b fix: allow native pallets for overseas destinations 2026-09-09 19:24:10 +08:00
ouyangxq e0d8b4f76a Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-tail-order-controls.mjs
2026-09-09 19:23:57 +08:00
ouyangxq 90a0b009da test: keep overseas acceptance fixtures independent 2026-09-09 19:16:18 +08:00
ouyangxq e30255ff58 fix: complete pallet code cross-region flow 2026-09-09 19:14:47 +08:00
ouyangxq 30b9db9628 test: align tail fee acceptance with current contract 2026-09-09 19:11:37 +08:00
ouyangxq 1e66fc6c22 fix: enforce dropship whole-pallet receiving 2026-09-09 19:07:13 +08:00
ouyangxq 3e01dec4f8 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 19:05:05 +08:00
ouyangxq 7678fd85ff test: cover customer inbound fee confirmation 2026-09-09 18:43:08 +08:00
ouyangxq d9603aa7da 修复统一增量中的IAM权限区块边界 2026-09-09 18:38:22 +08:00
ouyangxq 1c4f35f61c test: verify manual inbound unit price flow 2026-09-09 18:34:07 +08:00
ouyangxq 0d016bc5fc 适配财务验收FBA内部编号 2026-09-09 18:33:39 +08:00
ouyangxq 45e7c98b3f feat: receive dropship inbound by source pallet 2026-09-09 18:33:17 +08:00
ouyangxq c8ae626e87 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 18:31:14 +08:00
ouyangxq acf9bd7135 优化 2026-09-09 18:29:42 +08:00
ouyangxq 1dfb19276c Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java
2026-09-09 18:20:40 +08:00
ouyangxq 312501cc70 feat: use FBA internal code in pallet labels 2026-09-09 18:19:58 +08:00
ouyangxq 2a9f79604b 修复装柜完成托盘标签事务 2026-09-09 18:18:26 +08:00
ouyangxq 3b02affcb3 优化 2026-09-09 17:58:07 +08:00
ouyangxq 4fee718424 修复仓库内部编号增量迁移 2026-09-09 17:55:46 +08:00
ouyangxq fc5ce1f45f 完善财务P1验收客户规则快照 2026-09-09 17:46:36 +08:00
ouyangxq be882d53d1 优化 2026-09-09 17:42:44 +08:00
ouyangxq 88b489eb77 优化 2026-09-09 17:42:26 +08:00
ouyangxq 2b0e66ca7e test: cover pallet label loading-list paths 2026-09-09 17:35:47 +08:00
ouyangxq de1f7e8ddd feat: freeze and propagate first-leg pallet labels 2026-09-09 17:27:00 +08:00
ouyangxq 6f0622ed36 fix: close development regression gaps 2026-09-09 17:16:34 +08:00
ouyangxq 7c6fa248ac Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml
2026-09-09 17:07:34 +08:00
ouyangxq 48bc8bf0c1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java
2026-09-09 16:49:46 +08:00
ouyangxq 83e95d764e 修复海外业务接口契约格式 2026-09-09 16:49:01 +08:00
ouyangxq 7113703e3c 更新财务费用状态合同 2026-09-09 16:46:01 +08:00
ouyangxq 17b9f3eb73 统一财务费用来源入池链路 2026-09-09 16:39:20 +08:00
ouyangxq 95828a0ec6 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 15:41:50 +08:00
ouyangxq 9fdb199972 修复导出基础设施测试的三次重启策略断言 2026-09-09 15:41:32 +08:00
ouyangxq 2980308f4d 优化 2026-09-09 15:27:10 +08:00
liuyihe 1ee32b7a6f 优化 2026-09-09 15:14:15 +08:00
ouyangxq 5f671e164f 优化 2026-09-09 14:31:59 +08:00
ouyangxq 250b623f3a 限制容器异常退出后最多自动重启三次 2026-09-09 14:26:34 +08:00
ouyangxq 3727ff141c 补齐码头车队邮件表字段中文注释 2026-09-09 14:00:03 +08:00
ouyangxq f31a049444 优化 2026-09-09 13:54:47 +08:00
ouyangxq 1a092bc9d7 优化 2026-09-09 13:51:01 +08:00
liuyihe 90a2eda86a 优化 2026-09-09 13:41:24 +08:00
ouyangxq 748855793d fix(dropship): project sealed carton identities into overseas receiving 2026-09-09 13:20:11 +08:00
ouyangxq 90973532f0 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 13:13:51 +08:00
ouyangxq a3354c92c2 优化 2026-09-09 12:57:59 +08:00
ouyangxq 655237e37f fix(lastleg): complete relabel document contract and regression gates 2026-09-09 12:54:14 +08:00
ouyangxq ac3ce3703e fix(firstleg): preserve sealed pallet identity in atomic PDA picking 2026-09-09 12:33:31 +08:00
ouyangxq 44ac24a9e2 Merge remote-tracking branch 'origin/dev' into dev 2026-09-09 12:20:05 +08:00
ouyangxq 0d8539e72f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 12:19:17 +08:00
ouyangxq 5fb147f7aa fix(dropship): generate pallet numbers atomically and extend full regressions 2026-09-09 12:19:16 +08:00
liuyihe c3ae15abfe 优化 2026-09-09 12:02:12 +08:00
ouyangxq e5d826b4a2 优化 2026-09-09 11:55:06 +08:00
ouyangxq 465d9dfc10 feat(lastleg): manage three delivery document categories 2026-09-09 11:22:47 +08:00
ouyangxq f37e9ce555 fix(firstleg): allow optional ocean booking customer 2026-09-09 11:21:37 +08:00
ouyangxq 81b729929c fix(file): converge delivery documents to three categories 2026-09-09 11:21:36 +08:00
ouyangxq 156520b560 fix(file): make ocean booking documents platform owned 2026-09-09 11:16:28 +08:00
ouyangxq 3627239708 feat(file): support seven delivery document categories 2026-09-09 11:11:32 +08:00
ouyangxq cde8acf111 feat(iam): add delivery document management permission 2026-09-09 11:09:51 +08:00
ouyangxq 0d157e4dd9 test(firstleg): verify overseas list return action projection 2026-09-09 10:52:53 +08:00
ouyangxq 5ecee65720 test(finance): cover overseas fee replay and frozen lifecycle 2026-09-09 10:20:56 +08:00
ouyangxq 87d6469f02 优化 2026-09-09 10:18:04 +08:00
ouyangxq 7e2626513e fix(finance): preserve container identity and source version order 2026-09-09 10:14:42 +08:00
ouyangxq 4379b721bc 优化 2026-09-09 09:52:46 +08:00
ouyangxq 9d492cb5ed fix(overseas): restore dispatch delivery returns 2026-09-09 04:00:24 +08:00
ouyangxq 646221d133 test(overseas): use authoritative fulfillment fixtures 2026-09-09 03:42:48 +08:00
ouyangxq bfcec93017 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 03:40:54 +08:00
ouyangxq 68a5612598 fix(firstleg): preserve dropship palletization state during pricing 2026-09-09 03:22:07 +08:00
ouyangxq a99a54493b test(overseas): close fulfillment HTTP gaps 2026-09-09 03:18:59 +08:00
ouyangxq 47774ea8fe Merge remote-tracking branch 'origin/test' into dev 2026-09-09 03:16:56 +08:00
ouyangxq bf89a04883 fix(firstleg): price dropship before preload confirmation 2026-09-09 03:11:41 +08:00
ouyangxq cda6521078 test(overseas): align relabel and pallet acceptance 2026-09-09 03:02:36 +08:00
ouyangxq 71580da8d7 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-09 03:00:48 +08:00
ouyangxq c5862db98d fix(lastleg): complete generated pallet receiving flow 2026-09-09 02:54:09 +08:00
ouyangxq 045d0d815b test(overseas): cover split relabel inventory 2026-09-09 02:48:10 +08:00
ouyangxq 41225248d0 test(acceptance): reconcile dropship runtime facts 2026-09-09 02:40:42 +08:00
ouyangxq 4ccdae0665 fix(overseas): price generated receiving pallets 2026-09-09 02:17:38 +08:00
ouyangxq 66ba59a51a test(firstleg): accept pallet pick audit action 2026-09-09 01:57:15 +08:00
ouyangxq 96b5f66d5c Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
2026-09-09 01:56:29 +08:00
ouyangxq 86646ccd69 test(admin): scope domestic container lookup 2026-09-09 01:54:54 +08:00
ouyangxq 04c056f744 test(admin): unwrap container workbench details 2026-09-09 01:52:59 +08:00
ouyangxq 74fa4789f5 fix(acceptance): permit dropship pallet pick audit 2026-09-09 01:50:19 +08:00
ouyangxq c5d73b8e8f test(admin): use canonical container workbench routes 2026-09-09 01:49:52 +08:00
ouyangxq decfe4ab1f Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/inbound-fees-contract.test.mjs
2026-09-09 01:48:32 +08:00
ouyangxq 859fba4ecc test(admin): confirm fee adjustment tolerance 2026-09-09 01:46:11 +08:00
ouyangxq a0b1c761b7 fix(firstleg): complete inbound fee deployment schema 2026-09-09 01:33:03 +08:00
ouyangxq f7e3863898 fix(acceptance): follow reserved pallet workflows 2026-09-09 01:30:22 +08:00
ouyangxq 3cfd45f4bb fix(lastleg): preserve generated pallet FBA destination 2026-09-09 01:29:38 +08:00
ouyangxq b11ad31d5f Merge remote-tracking branch 'origin/test' into dev 2026-09-09 01:28:48 +08:00
ouyangxq b797af706a test(admin): sync fee item baseline 2026-09-09 01:27:44 +08:00
ouyangxq 8452f1410f fix(firstleg): hide provider framework errors 2026-09-09 01:20:08 +08:00
ouyangxq 2abbb3cc8f fix(acceptance): preserve calculated dropship fees 2026-09-09 01:09:31 +08:00
ouyangxq 0d73e185c0 fix(acceptance): align latest overseas contracts 2026-09-09 00:53:40 +08:00
ouyangxq 5da3417429 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
2026-09-09 00:41:47 +08:00
ouyangxq e7cbe98766 fix(lastleg): restore pallet validation structure 2026-09-09 00:39:38 +08:00
ouyangxq df0088750b fix(lastleg): restore receiving service structure 2026-09-09 00:37:42 +08:00
ouyangxq 2ba5f0c307 fix(lastleg): harden container plan saga recovery 2026-09-09 00:31:50 +08:00
ouyangxq 990b74f3e8 fix(lastleg): confirm pre-reserved pallets on PDA scan 2026-09-09 00:25:57 +08:00
ouyangxq 7e76fed728 fix(acceptance): complete overseas fulfillment closure 2026-09-09 00:25:34 +08:00
ouyangxq 094d9c1e81 Merge remote-tracking branch 'origin/test' into dev 2026-09-09 00:19:29 +08:00
ouyangxq 7b47de47c6 feat(lastleg): reserve pallets for container plans 2026-09-09 00:15:23 +08:00
ouyangxq cf8053b7ce fix(warehouse-overseas): keep confirmed pallet locks active 2026-09-09 00:11:46 +08:00
ouyangxq 93704a24ce fix(warehouse-overseas): avoid pallet batch gap lock 2026-09-08 23:59:50 +08:00
ouyangxq 5086de23a7 fix(acceptance): close overseas dropship pallet chain 2026-09-08 23:56:54 +08:00
ouyangxq fa9888d0e1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/db/incremental.sql
2026-09-08 23:54:12 +08:00
ouyangxq a88a04fd24 feat(warehouse-overseas): reserve inbound pallet batches 2026-09-08 23:52:36 +08:00
ouyangxq 4c1e9aa714 feat(lastleg): add container plan preview 2026-09-08 23:45:30 +08:00
ouyangxq 0c984e8f5f fix(lastleg): inspect occupied pallets for PDA queries 2026-09-08 23:34:12 +08:00
ouyangxq 3e15ad6b5b fix(scripts): align dropship container acceptance 2026-09-08 23:26:09 +08:00
ouyangxq 2121743753 fix(scripts): scope container workbench queries 2026-09-08 23:16:52 +08:00
ouyangxq 7d64135e79 fix(scripts): read domestic container list payload 2026-09-08 23:15:13 +08:00
ouyangxq 735b5abdcc fix(scripts): read container workbench payload 2026-09-08 23:11:49 +08:00
ouyangxq 00990fedcc fix(scripts): use domestic container detail route 2026-09-08 23:06:07 +08:00
ouyangxq f7f4c80806 fix(firstleg): sync container pickup code schema 2026-09-08 22:51:02 +08:00
ouyangxq 0b9cab7c1a fix(firstleg): align dropship pallet sealing contract 2026-09-08 22:34:58 +08:00
ouyangxq 4bbcda2a75 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
2026-09-08 22:34:48 +08:00
ouyangxq ae44b82a85 fix(lastleg): align overseas schema upgrades 2026-09-08 22:17:36 +08:00
ouyangxq 8e3e94b286 fix(lastleg): sync relabel inventory snapshot schema 2026-09-08 22:12:34 +08:00
ouyangxq a060f8dca8 fix(overseas): support projected authorization headers 2026-09-08 21:59:13 +08:00
ouyangxq 55fda1f616 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:59:03 +08:00
ouyangxq e3a5ea7341 feat(lastleg): add overseas container fee sources 2026-09-08 21:45:57 +08:00
ouyangxq 0c4d7c644e fix(gateway): forward standard internal trust header 2026-09-08 21:38:12 +08:00
ouyangxq e9ad7e333a fix(security): distinguish gateway context failures 2026-09-08 21:29:23 +08:00
ouyangxq 781669dfaf Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:29:21 +08:00
ouyangxq 1cf6722d6e fix(finance): key container finance by booking 2026-09-08 21:27:23 +08:00
ouyangxq 55c36cf290 fix(firstleg): publish fees by container booking 2026-09-08 21:24:49 +08:00
ouyangxq d0c4d5f997 test(security): cover gateway token authentication 2026-09-08 21:22:24 +08:00
ouyangxq 23ac00eb61 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 21:05:33 +08:00
ouyangxq 4a403aaa30 fix(security): accept overseas token subject identity 2026-09-08 21:05:15 +08:00
ouyangxq 1fb139bc71 test(finance): sync formal fee item baseline 2026-09-08 21:05:15 +08:00
ouyangxq 8e0001dc56 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 21:05:05 +08:00
ouyangxq 2e2ca3bef4 feat(finance): add container settlement aggregate 2026-09-08 21:03:47 +08:00
liuyihe f81fac8f6b Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 20:52:55 +08:00
ouyangxq aa7f067e54 test(firstleg): confirm fee adjustment tolerance 2026-09-08 20:51:08 +08:00
liuyihe c6156771b4 Merge remote-tracking branch 'origin/dev' into dev 2026-09-08 20:50:41 +08:00
liuyihe 4d4008cdb3 Merge origin/dev and resolve backend conflicts 2026-09-08 20:50:28 +08:00
ouyangxq 93bf192e3d fix(lastleg): sync delivery lifecycle schema 2026-09-08 20:37:41 +08:00
ouyangxq cafa66fd37 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 20:37:19 +08:00
ouyangxq fc079fcdc0 fix(firstleg): allow domestic history container details 2026-09-08 20:29:05 +08:00
liuyihe 249bf0ab4b 修改提交 2026-09-08 20:25:05 +08:00
ouyangxq da5cf0eb64 fix(deploy): sync inbound fee pricing columns 2026-09-08 20:23:02 +08:00
ouyangxq 11076cef00 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 20:22:49 +08:00
ouyangxq b19b828861 fix(sql): sync phase5 delivery permissions 2026-09-08 20:18:35 +08:00
ouyangxq 2f01fadeeb chore(sql): sync container workspace permissions 2026-09-08 20:05:17 +08:00
ouyangxq d94c902d75 feat(firstleg): split domestic and overseas container workbenches 2026-09-08 19:48:16 +08:00
ouyangxq 19d9a89651 feat(iam): split domestic and overseas container permissions 2026-09-08 19:11:29 +08:00
ouyangxq b0c81e337d feat(firstleg): expose batch carrier actual facts 2026-09-08 19:09:08 +08:00
ouyangxq 75e67fa980 feat(iam): add exception order process permission 2026-09-08 18:51:22 +08:00
ouyangxq d1090671ce fix(lastleg): enforce delivery and return workflows 2026-09-08 18:49:52 +08:00
ouyangxq d42629d289 feat(lastleg): add direct inventory balance detail 2026-09-08 18:47:15 +08:00
ouyangxq 82337da0cc Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:45:13 +08:00
ouyangxq 6514634dcb fix(lastleg): align receiving warehouse checks 2026-09-08 18:41:00 +08:00
ouyangxq 3e7fa40a99 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java
2026-09-08 18:39:34 +08:00
ouyangxq 47419aad00 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java
2026-09-08 18:37:23 +08:00
ouyangxq f6b0bca2bf test(firstleg): mock container tier mapper 2026-09-08 18:32:59 +08:00
ouyangxq db98da5689 fix(lastleg): restore returned inventory from shipment source 2026-09-08 18:30:04 +08:00
ouyangxq 047ed674b8 feat(file): support overseas exception evidence 2026-09-08 18:26:26 +08:00
ouyangxq 3b88e5f6ab fix(firstleg): reprice container fee on loading changes 2026-09-08 18:24:04 +08:00
ouyangxq cd47317e8a test(firstleg): provide container pricing mapper fixture 2026-09-08 18:21:46 +08:00
ouyangxq 6ac80bf659 Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:21:46 +08:00
ouyangxq 1fd2b544a5 优化 2026-09-08 18:18:51 +08:00
ouyangxq fcb4c055e2 优化 2026-09-08 18:16:54 +08:00
ouyangxq c6657929da Merge remote-tracking branch 'origin/test' into dev 2026-09-08 18:16:53 +08:00
ouyangxq 1a1a19dea7 feat(firstleg): tier container customer freight 2026-09-08 18:04:14 +08:00
ouyangxq 3aaba2d6a3 fix: order dropship schema convergence before assertions 2026-09-08 18:02:47 +08:00
ouyangxq 13d8640b4c Merge remote-tracking branch 'origin/test' into dev 2026-09-08 17:59:53 +08:00
liuyihe 2a27e28f26 修改提交 2026-09-08 17:58:42 +08:00
ouyangxq f3a0a45e59 fix(lastleg): stock receipts into scanned locations 2026-09-08 17:55:29 +08:00
ouyangxq b0fd797cd2 feat(firstleg): support customer price books 2026-09-08 17:42:20 +08:00
ouyangxq 751bdbf880 fix: converge dropship development schema 2026-09-08 17:38:43 +08:00
ouyangxq 0a9c798580 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
#	order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java
#	order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java
2026-09-08 17:34:12 +08:00
ouyangxq 60a41ec3ea fix: reserve stable inbound code for draft label printing 2026-09-08 17:33:44 +08:00
ouyangxq eead11e99d feat(order): add batch review preview 2026-09-08 17:32:56 +08:00
ouyangxq 442518c382 feat(firstleg): add private warehouse delivery fee 2026-09-08 17:27:20 +08:00
ouyangxq 6c2d169798 feat: complete dropship palletization workflow 2026-09-08 17:24:48 +08:00
ouyangxq ba81dece5e feat(firstleg): expose authoritative measurement identifiers 2026-09-08 17:24:29 +08:00
ouyangxq e597bebafd feat(order): expose cargo shipment binding 2026-09-08 17:17:57 +08:00
ouyangxq 2e60f2cd77 feat(firstleg): enforce pricing adjustment bounds 2026-09-08 17:13:43 +08:00
ouyangxq 78a73a1b0e feat(firstleg): expose loading item tax mode 2026-09-08 17:07:06 +08:00
ouyangxq e9182e8cfe Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
2026-09-08 17:01:59 +08:00
ouyangxq 40fe0ba0e1 fix(lastleg): keep inventory warehouse snapshots canonical 2026-09-08 16:16:20 +08:00
ouyangxq 6908969307 fix(lastleg): reject unavailable receiving pallets 2026-09-08 14:52:08 +08:00
ouyangxq a0ad3382af fix(warehouse-overseas): enforce pallet location invariant 2026-09-08 14:47:35 +08:00
ouyangxq 07688005a1 优化 2026-09-08 14:07:31 +08:00
ouyangxq d318f4a450 fix(lastleg): create remaining container plans 2026-09-08 13:52:37 +08:00
ouyangxq 3c05525e27 fix(lastleg): search frozen warehouse code and name 2026-09-08 13:14:13 +08:00
ouyangxq 096ab8d48b fix(test): align receiving pallets with FBA destinations 2026-09-08 12:45:46 +08:00
ouyangxq 847df6a85f fix(lastleg): keep completed containers editable 2026-09-08 11:51:04 +08:00
ouyangxq cc6df5993f fix(lastleg): enforce container warehouse plan facts 2026-09-08 11:00:39 +08:00
ouyangxq 61ffff1be0 fix: keep terminal drayage actions executable 2026-09-08 07:39:17 +08:00
ouyangxq 287c0f3ce8 fix: publish holds for local overseas orders 2026-09-08 00:51:32 +08:00
ouyangxq bb68e8a580 fix: route terminal drayage through gateway 2026-09-08 00:23:40 +08:00
ouyangxq eeb087e453 fix: make terminal drayage migration restartable 2026-09-08 00:11:31 +08:00
ouyangxq e32fe65fa8 feat: complete tail order controls and terminal drayage 2026-09-07 23:58:18 +08:00
ouyangxq f018ca85a9 test(overseas): retain marker merchant fixture 2026-09-07 21:26:03 +08:00
ouyangxq ac18f69ec5 fix(lastleg): preserve dropship source pallets 2026-09-07 21:10:43 +08:00
ouyangxq 98dafc13ca fix(lastleg): align outbound pallet workflows 2026-09-07 20:51:03 +08:00
ouyangxq 4895c4f738 优化 2026-09-07 19:59:31 +08:00
ouyangxq df8610f458 fix(lastleg): refresh received pallets before delivery transfer 2026-09-07 19:16:30 +08:00
ouyangxq e0bb7de0cf fix: isolate invalid container progress snapshots 2026-09-07 16:36:21 +08:00
ouyangxq d622ddd37f fix: isolate invalid receiving candidates 2026-09-07 16:06:08 +08:00
ouyangxq ff2b02b102 fix: finalize smart dispatch drafts 2026-09-07 15:54:10 +08:00
ouyangxq f0939c91c3 优化 2026-09-07 15:34:36 +08:00
ouyangxq 1b47158770 优化 2026-09-07 15:07:34 +08:00
ouyangxq 693e10188c fix: refresh overseas authorization on user status change 2026-09-07 14:22:26 +08:00
ouyangxq 2c5fda33e7 fix: complete overseas fulfillment acceptance 2026-09-07 14:03:00 +08:00
ouyangxq fcf1771967 fix: restore overseas list queries and identifiers 2026-09-07 12:30:17 +08:00
ouyangxq 30e883fe0c test: complete held binding fixture 2026-09-07 12:17:56 +08:00
ouyangxq 51e5c6dc64 Merge remote-tracking branch 'origin/test' into dev 2026-09-07 12:17:01 +08:00
ouyangxq e281a8f20b fix: preserve hold binding replays 2026-09-07 12:16:55 +08:00
ouyangxq 0aecc90d11 test: cover concurrent inbound hold 2026-09-07 12:16:19 +08:00
ouyangxq a077f828ae feat: return authorization snapshot outside jwt 2026-09-07 12:14:48 +08:00
ouyangxq c6b598daa7 Merge remote-tracking branch 'origin/test' into dev 2026-09-07 12:10:19 +08:00
ouyangxq 41582fe17b fix: close inbound hold bypass paths 2026-09-07 12:10:11 +08:00
ouyangxq a99ca36316 fix: align overseas outbound schema entry 2026-09-07 11:48:49 +08:00
ouyangxq 4963889024 fix: project overseas authorization outside jwt 2026-09-07 11:37:06 +08:00
ouyangxq 6b479d1a68 test: expand inbound hold acceptance 2026-09-07 11:22:57 +08:00
ouyangxq 0c8f6fe2b3 feat: add inbound hold lifecycle 2026-09-07 11:20:10 +08:00
ouyangxq e52ecb9315 merge: sync test into dev before hold flow 2026-09-07 10:37:14 +08:00
liuyihe 9d213b3843 修改提交 2026-09-06 16:02:11 +08:00
liuyihe 3ae8964fe5 修改提交 2026-09-06 15:02:33 +08:00
ouyangxq 7fdc58380f test: include relabel charge amount 2026-09-06 12:24:42 +08:00
ouyangxq 1a7d4bb39f fix: order wallet database assertions 2026-09-06 12:02:11 +08:00
ouyangxq 42ddc19826 test: deduplicate acceptance coverage gaps 2026-09-06 11:41:46 +08:00
ouyangxq 54af43a99d test: align wallet ledger assertions with contract 2026-09-06 11:15:48 +08:00
ouyangxq 0fbab09890 test: preserve uploaded file ids in wallet flow 2026-09-06 11:09:53 +08:00
ouyangxq 20b3074cb1 test: automate overseas wallet and pallet cost closure 2026-09-06 11:00:06 +08:00
ouyangxq 64bf5aa0fe fix: accept wallet result event hashes 2026-09-05 23:32:31 +08:00
ouyangxq 81917423eb fix: relay wallet result events 2026-09-05 23:24:34 +08:00
ouyangxq af7852416a fix: accept prefixed wallet event hashes 2026-09-05 23:13:24 +08:00
ouyangxq 3c535731f7 fix: configure finance wallet event delivery 2026-09-05 22:59:08 +08:00
ouyangxq 718a54ec9e fix: bound wallet event metric labels 2026-09-05 22:34:56 +08:00
ouyangxq 5fbd2bbd04 feat: close overseas wallet and pallet cost flow 2026-09-05 22:26:25 +08:00
ouyangxq f62283fb7f test(finance): cover internal wallet token boundary 2026-09-05 16:46:40 +08:00
ouyangxq 8400636df4 test(finance): expand wallet credit boundary coverage 2026-09-05 16:43:33 +08:00
ouyangxq ede63fac24 fix(finance): align permission migration totals 2026-09-05 12:16:57 +08:00
ouyangxq ba4407eb0d feat(finance): add credit and wallet hold lifecycle 2026-09-05 11:54:55 +08:00
ouyangxq 6f2b2babc6 fix(web): map missing multipart parts to bad request 2026-09-05 10:44:13 +08:00
ouyangxq 37183ac2f0 fix(deploy): identify development wallet runtime 2026-09-05 09:01:43 +08:00
ouyangxq 3df6fb511f fix(finance): expose sample recharge QR in dev 2026-09-05 08:56:02 +08:00
ouyangxq cf955477d8 fix(finance): enable development recharge channels 2026-09-05 08:50:39 +08:00
ouyangxq b4fff67303 chore(finance): enable development recharge sample 2026-09-05 08:41:24 +08:00
ouyangxq ad101ea42e fix(sql): preserve quoted audit comments 2026-09-05 08:39:24 +08:00
ouyangxq 5792630231 feat(finance): add customer wallet recharge flow 2026-09-05 08:34:40 +08:00
ouyangxq 6002e4223b test: align smart loading mapper signature 2026-09-04 22:18:19 +08:00
ouyangxq 7bfccc87fc test: guard unified sql comment ordering 2026-09-04 18:54:12 +08:00
ouyangxq 77817b4c94 优化 2026-09-04 18:47:57 +08:00
ouyangxq 7c5ed85a36 test: select live unpaid settlement fixture 2026-09-04 18:16:27 +08:00
ouyangxq 991bab9cb1 test: stabilize full development acceptance 2026-09-04 17:17:08 +08:00
ouyangxq 61b7b3fb02 test: align task registry index contract 2026-09-04 16:43:51 +08:00
ouyangxq 07bcf316e8 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/acceptance-finance-full-contract.test.mjs
#	scripts/admin-test-env-acceptance.mjs
2026-09-04 16:43:43 +08:00
ouyangxq 1e980638c4 优化 2026-09-04 16:23:38 +08:00
ouyangxq 23bb915d92 test: preserve direct paid finance ordering 2026-09-04 16:20:09 +08:00
ouyangxq d284fc0637 优化 2026-09-04 16:06:47 +08:00
ouyangxq b8a649e705 test: retry transient unauthenticated upload probe 2026-09-04 16:04:12 +08:00
ouyangxq e477f73983 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	scripts/AGENTS.md
#	scripts/admin-test-env-modules/README.md
#	scripts/agents-index-contract.ps1
2026-09-04 16:03:09 +08:00
ouyangxq 56a6e690ed 优化 2026-09-04 16:02:05 +08:00
ouyangxq 1f1a7825e3 优化 2026-09-04 16:02:03 +08:00
ouyangxq c6282e7e2f test: align smart loading limit error code 2026-09-04 15:56:17 +08:00
ouyangxq 6cd09ed2fa 优化 2026-09-04 15:55:43 +08:00
ouyangxq dc6a0debca fix: deduplicate unified order locations 2026-09-04 15:54:53 +08:00
ouyangxq c3f45c6bff fix: refresh dimension config response 2026-09-04 15:44:00 +08:00
ouyangxq eba86bb666 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs
2026-09-04 15:41:18 +08:00
ouyangxq 15b16634db test: cover smart loading quantity operations 2026-09-04 15:36:56 +08:00
ouyangxq 9c96bdfaa8 test: require incremental pallet growth 2026-09-04 15:30:59 +08:00
ouyangxq 7babcb9e0d test: expand truck attachment acceptance contract 2026-09-04 15:29:43 +08:00
ouyangxq c8c402ffc5 test: detect shared overflow evidence 2026-09-04 15:29:06 +08:00
ouyangxq c55f2aed11 test: add pallet location http matrix 2026-09-04 15:24:45 +08:00
ouyangxq 4aed53a8af 优化 2026-09-04 15:19:45 +08:00
ouyangxq 1c8d058d71 test: avoid duplicate overseas PDA lock assertion 2026-09-04 15:18:57 +08:00
ouyangxq 21796cb83d Merge remote-tracking branch 'origin/test' into dev 2026-09-04 15:16:16 +08:00
ouyangxq 2c21d27f61 fix: keep location guidance non-blocking 2026-09-04 15:15:12 +08:00
ouyangxq e23c5dcb7b 优化 2026-09-04 15:06:36 +08:00
ouyangxq 8a0366af5b fix: reserve locations from measured volume 2026-09-04 15:05:44 +08:00
ouyangxq f94fbaa9b4 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-04 14:50:25 +08:00
ouyangxq 5e013489e0 优化 2026-09-04 14:41:23 +08:00
ouyangxq 760c7b561d fix: stabilize smart loading plan reads 2026-09-04 14:28:25 +08:00
ouyangxq 2c94e212ca 优化 2026-09-04 14:17:34 +08:00
ouyangxq 053c538f94 fix: preserve pallet snapshots across partial pick 2026-09-04 14:09:41 +08:00
ouyangxq 3399ae7488 fix: return A0404 for missing PDA pallets 2026-09-04 14:07:18 +08:00
ouyangxq f6247ce232 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 14:06:02 +08:00
ouyangxq b79d136925 优化 2026-09-04 14:04:52 +08:00
ouyangxq f70d38a034 优化 2026-09-04 14:04:23 +08:00
ouyangxq b428ac1c6c Merge remote-tracking branch 'origin/test' into dev 2026-09-04 14:04:07 +08:00
ouyangxq 93f06f8ff8 fix: fall back to order declared volume 2026-09-04 14:03:04 +08:00
ouyangxq a08559e163 test: use original token for dynamic stale snapshot 2026-09-04 13:50:11 +08:00
ouyangxq 7feb80689b test: verify pallet block settlement over HTTP 2026-09-04 13:48:03 +08:00
ouyangxq 7d9b2749d5 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 13:47:47 +08:00
ouyangxq 660e284e25 fix: reserve pallet block from declared volume 2026-09-04 13:28:57 +08:00
ouyangxq 944ecb2bf3 fix: allocate pallet locations on weight confirmation 2026-09-04 13:21:37 +08:00
ouyangxq 81470359f2 fix: include customer cross-border authorities overseas 2026-09-04 13:15:28 +08:00
ouyangxq 54cb9ceade feat: complete overseas customer and PDA workflows 2026-09-04 12:59:37 +08:00
ouyangxq a274d029bb Merge remote-tracking branch 'origin/test' into dev 2026-09-04 12:55:52 +08:00
ouyangxq d6956d396c fix: remove legacy PDA release columns 2026-09-04 12:51:17 +08:00
ouyangxq 58bf9a3503 docs: register pallet guide acceptance 2026-09-04 12:49:15 +08:00
ouyangxq f556d3ca7e test: cover pallet location guide over HTTP 2026-09-04 12:48:55 +08:00
ouyangxq db6ed676ac test: align pallet block contracts 2026-09-04 12:35:55 +08:00
ouyangxq 209255f17e 优化 2026-09-04 12:31:52 +08:00
ouyangxq 9bf0b4d331 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	order/AGENTS.md
2026-09-04 12:29:57 +08:00
ouyangxq 1407f616a1 Merge remote-tracking branch 'origin/test' into test 2026-09-04 12:29:25 +08:00
ouyangxq 8099343fca fix: align PDA inventory with pallet location blocks 2026-09-04 12:28:47 +08:00
liuyihe 5c094fd0fd 修改提交 2026-09-04 12:23:26 +08:00
ouyangxq b697e4c9f0 优化 2026-09-04 12:18:36 +08:00
ouyangxq 566b07be62 Merge remote-tracking branch 'origin/test' into test 2026-09-04 12:03:17 +08:00
ouyangxq b971ca2f7c test: expand domestic container acceptance coverage 2026-09-04 12:02:38 +08:00
liuyihe 6f32971d79 修改提交 2026-09-04 11:50:01 +08:00
ouyangxq 55cad93be3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-04 11:35:21 +08:00
ouyangxq f23f3ea26d 优化 2026-09-04 11:35:19 +08:00
liuyihe 451c791f10 修改提交 2026-09-04 11:34:25 +08:00
ouyangxq c3729249c4 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-04 10:51:26 +08:00
ouyangxq 7a0e158b51 优化 2026-09-04 10:51:25 +08:00
ouyangxq da23683440 Merge remote-tracking branch 'origin/test' into dev 2026-09-04 10:37:19 +08:00
ouyangxq a0ba933db3 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java
#	scripts/admin-test-env-modules/inbound-measurement.mjs
2026-09-04 10:37:12 +08:00
liuyihe e6859c470c 修改提交 2026-09-04 10:34:35 +08:00
liuyihe 4ca5a57e6a 修改提交 2026-09-04 10:13:46 +08:00
ouyangxq edadca18c6 perf(firstleg): streamline smart loading assignments 2026-09-04 09:57:39 +08:00
ouyangxq b5be60140e test(firstleg): align full acceptance contracts 2026-09-04 00:41:34 +08:00
ouyangxq ce3cd7e80f fix(firstleg): preserve coload groups in filtered candidates 2026-09-04 00:33:07 +08:00
ouyangxq 65b29adb01 修复智能装柜日期筛选调用 2026-09-03 22:05:27 +08:00
ouyangxq d3eaa901f5 优化 2026-09-03 21:50:21 +08:00
ouyangxq b8826c3e92 优化 2026-09-03 21:48:46 +08:00
ouyangxq eca2c94aa3 优化 2026-09-03 21:48:40 +08:00
ouyangxq 2b461cc6e8 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 21:34:28 +08:00
ouyangxq a5aecf6171 优化 2026-09-03 21:34:27 +08:00
liuyihe da4cb16d52 Merge remote-tracking branch 'origin/test' into test 2026-09-03 21:33:33 +08:00
liuyihe bfb139c7c7 修改提交 2026-09-03 21:33:23 +08:00
ouyangxq effab80db9 Merge branch 'test' into dev 2026-09-03 21:30:08 +08:00
ouyangxq 26c2d7aacd 修复设备测量箱规计算编译错误 2026-09-03 21:29:51 +08:00
ouyangxq 2baf1b1460 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 21:25:54 +08:00
ouyangxq dd1682a83b 优化 2026-09-03 21:25:53 +08:00
ouyangxq 1a3f94dfcb Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java
2026-09-03 21:23:35 +08:00
liuyihe baabfdabba 修改提交 2026-09-03 21:09:22 +08:00
ouyangxq 0ad8bb66cc test: bound reused price fixture names 2026-09-03 21:00:11 +08:00
ouyangxq 47a043324f Merge remote-tracking branch 'origin/test' into dev 2026-09-03 20:59:58 +08:00
ouyangxq 171f450e59 优化 2026-09-03 20:38:47 +08:00
ouyangxq a24d55f526 优化 2026-09-03 20:24:52 +08:00
ouyangxq 8b2c2b32b2 test: align tally permission total 2026-09-03 19:41:25 +08:00
ouyangxq 2063d29d16 fix: complete development acceptance paths 2026-09-03 19:10:18 +08:00
ouyangxq 4df0ae4811 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 19:09:04 +08:00
ouyangxq acdc0943c0 feat(order): derive batch customer context 2026-09-03 18:22:44 +08:00
ouyangxq 514a585de2 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	AGENTS.md
#	firstleg/AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-03 18:22:05 +08:00
ouyangxq ca288dec72 优化 2026-09-03 18:13:00 +08:00
ouyangxq 66ad9f5c85 优化 2026-09-03 18:05:34 +08:00
ouyangxq 61adfc7818 feat(order): refine batch import workbook 2026-09-03 18:03:42 +08:00
ouyangxq 880f923462 Merge remote-tracking branch 'origin/dev' into dev 2026-09-03 17:52:38 +08:00
ouyangxq 03591d6fbd test(firstleg): 覆盖超限设备测量实际尺寸 2026-09-03 17:52:31 +08:00
ouyangxq 1d8432b8ef Merge remote-tracking branch 'origin/test' into dev 2026-09-03 17:48:01 +08:00
ouyangxq d7132536a8 Merge branch 'test' into dev 2026-09-03 17:44:23 +08:00
ouyangxq 8911d4a242 固定 Jenkins Job 部署环境 2026-09-03 17:43:52 +08:00
ouyangxq 6130a13b6b 修复超限单箱测量尺寸计算 2026-09-03 17:17:57 +08:00
ouyangxq b8bdff2f1f fix(lastleg): preserve readable exception operators 2026-09-03 17:17:10 +08:00
ouyangxq 997419ea6b Merge remote-tracking branch 'origin/test' into dev 2026-09-03 17:14:16 +08:00
ouyangxq 253ca82b99 fix(order): load all warehouse template options 2026-09-03 17:04:57 +08:00
ouyangxq 3664f036cc feat(order): localize batch import workbook 2026-09-03 16:33:33 +08:00
ouyangxq 5c524fb92c test: stabilize phase5 acceptance inventory fixture 2026-09-03 16:20:17 +08:00
ouyangxq a66ecfab98 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-09-03 16:19:48 +08:00
ouyangxq dac02e5253 优化 2026-09-03 16:09:22 +08:00
ouyangxq 0307acf859 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 15:35:28 +08:00
ouyangxq 36d35276b7 优化 2026-09-03 15:35:26 +08:00
ouyangxq 904b495613 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/maven/settings-cn.xml
2026-09-03 15:23:55 +08:00
liuyihe 29ec202563 修改提交 2026-09-03 15:11:40 +08:00
liuyihe e29695eb3f 修改提交 2026-09-03 15:10:35 +08:00
ouyangxq 6eff5a4b9b 优化 2026-09-03 15:00:55 +08:00
ouyangxq 55592a1f08 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-09-03 13:49:41 +08:00
ouyangxq a927903a66 优化 2026-09-03 13:49:38 +08:00
ouyangxq 2041819df5 优化 2026-09-03 13:35:17 +08:00
ouyangxq 732fc66670 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 12:01:00 +08:00
ouyangxq 0d52ee5948 修复订单申报更新测试依赖装配 2026-09-03 11:59:25 +08:00
liuyihe fb91168614 Merge remote-tracking branch 'origin/test' into test 2026-09-03 11:59:15 +08:00
liuyihe f561e6bb09 修改提交 2026-09-03 11:59:06 +08:00
ouyangxq 8de52f65bd Merge remote-tracking branch 'origin/test' into dev 2026-09-03 11:55:55 +08:00
ouyangxq bdcc32a095 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-09-03 11:55:46 +08:00
ouyangxq 59b42c7579 优化 2026-09-03 11:54:17 +08:00
ouyangxq fd3dbb2fc6 优化 2026-09-03 11:47:28 +08:00
ouyangxq b15d003217 Merge remote-tracking branch 'origin/test' into dev 2026-09-03 11:34:52 +08:00
ouyangxq 757f6ef24f 修复头程测量与计费测试门禁 2026-09-03 11:32:08 +08:00
ouyangxq d56af7e036 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
2026-09-03 11:03:25 +08:00
liuyihe a3715f618f 一件代发拣货 2026-09-03 10:44:34 +08:00
ouyangxq cf77e19765 feat(file): expedite pending export retries 2026-09-03 10:40:21 +08:00
ouyangxq 6b2339e50a feat(file): allow immediate retry during export backoff 2026-09-03 10:39:55 +08:00
ouyangxq 16a60a9175 fix(file): align order export batch limit 2026-09-03 10:23:01 +08:00
ouyangxq 12b749d7d8 fix(iam): grant domestic order export permission 2026-09-03 10:12:08 +08:00
ouyangxq 7359fbc093 fix: hide completed receiving containers 2026-09-03 10:08:57 +08:00
ouyangxq 59add49408 fix: synchronize order declaration schema 2026-09-02 20:49:26 +08:00
ouyangxq 11d89435f5 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-acceptance.mjs
#	scripts/agents-index-contract.ps1
#	scripts/full-regression-entry-contract.test.mjs
2026-09-02 20:32:23 +08:00
ouyangxq 6f5398770b feat(finance): expose inbound code on bill lines 2026-09-02 20:25:54 +08:00
ouyangxq e4ee999098 优化 2026-09-02 20:14:47 +08:00
ouyangxq fa9a98f078 优化 2026-09-02 19:53:07 +08:00
ouyangxq 71f6dfa1eb test: extend finance and cross-region acceptance 2026-09-02 19:29:23 +08:00
ouyangxq 4521090967 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 18:40:20 +08:00
ouyangxq 9adbdfbcc1 统一财务事件投递端口 2026-09-02 18:28:39 +08:00
ouyangxq 4e2703295c fix: use published fees for client phase2 acceptance 2026-09-02 18:25:42 +08:00
ouyangxq b998a0c073 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 18:25:19 +08:00
ouyangxq 30090e741f 修复财务事件桥投递地址 2026-09-02 18:24:39 +08:00
ouyangxq eef45eda65 fix: synchronize unified firstleg migration 2026-09-02 18:17:00 +08:00
ouyangxq 8246093af1 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	auth/AGENTS.md
#	auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java
#	auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java
2026-09-02 18:14:50 +08:00
ouyangxq 596a5c925c 支持柜费按订单归属分摊 2026-09-02 18:14:25 +08:00
ouyangxq 62855915fc 补齐海外财务权限令牌 2026-09-02 18:09:43 +08:00
ouyangxq 6be5e0d81f 优化 2026-09-02 18:03:36 +08:00
ouyangxq 6296f032f0 fix: retain scanned carton traceability in measurement drafts 2026-09-02 17:57:35 +08:00
ouyangxq 2a0f942234 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java
#	firstleg/src/main/resources/db/phase3-container-customs-schema.sql
#	order/src/main/resources/db/order.sql
#	scripts/db/incremental.sql
2026-09-02 17:56:20 +08:00
ouyangxq 18c673811f 补齐财务事件监控标签 2026-09-02 17:45:17 +08:00
ouyangxq 5099a46581 补齐统一数据库中文注释 2026-09-02 17:36:37 +08:00
ouyangxq 3bdaba2c60 完善财务费用来源与柜级分摊 2026-09-02 17:07:03 +08:00
ouyangxq c5c80d5ab6 fix(lastleg): align deployment schema contracts 2026-09-02 16:55:38 +08:00
ouyangxq e06c12c263 fix(lastleg): resolve exceptions by inbound relation 2026-09-02 16:48:45 +08:00
ouyangxq 6cd477a037 修正四期全量验收夹具 2026-09-02 16:48:05 +08:00
ouyangxq a8c71fef12 fix(acceptance): select booking agents by carrier 2026-09-02 16:38:44 +08:00
ouyangxq 1b1f2718b3 fix(firstleg): sync finance allocation schema 2026-09-02 16:24:47 +08:00
ouyangxq 42021b351e Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java
2026-09-02 16:19:06 +08:00
ouyangxq 03d04431f2 优化 2026-09-02 16:17:41 +08:00
ouyangxq 12fbf2aa67 隔离头程定时任务执行线程 2026-09-02 16:10:52 +08:00
ouyangxq 7eabbdcd45 fix(firstleg): include declaration sync schema 2026-09-02 16:07:32 +08:00
ouyangxq 57d0073b40 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-02 16:04:08 +08:00
ouyangxq 601ae83504 收敛海外PDA回归夹具选择 2026-09-02 16:01:58 +08:00
ouyangxq cc04a5570c fix(acceptance): update declaration before fulfillment convergence 2026-09-02 15:59:13 +08:00
ouyangxq 337033b350 fix(acceptance): read nested order declaration details 2026-09-02 15:53:11 +08:00
ouyangxq afdee17ec0 Merge remote-tracking branch 'origin/test' into dev 2026-09-02 15:53:11 +08:00
ouyangxq 5f688d6131 修正海外PDA实装箱唛验收夹具 2026-09-02 15:47:13 +08:00
ouyangxq feda5cd061 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java
2026-09-02 15:46:10 +08:00
ouyangxq ef74579f2c test(overseas): align phase four to five acceptance contracts 2026-09-02 15:43:11 +08:00
ouyangxq 137e4e7c60 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
2026-09-02 15:43:00 +08:00
ouyangxq ecf4eb1211 优化 2026-09-02 15:42:22 +08:00
ouyangxq 2902ab6077 fix(deploy): prioritize BaoTa-visible container logs 2026-09-02 15:39:58 +08:00
ouyangxq 25c4dd50b3 fix(deploy): bound infrastructure container logs 2026-09-02 15:30:29 +08:00
ouyangxq 9e0d2f6cdb 修正海外PDA候选验收门禁判断 2026-09-02 15:25:30 +08:00
ouyangxq e946d99ea3 docs: clarify inbound plan pending filter 2026-09-02 15:24:56 +08:00
ouyangxq 0c36ea0e12 docs: align repository guidance with test contracts 2026-09-02 15:22:16 +08:00
ouyangxq eab8a1ae5f Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
#	scripts/agents-index-contract.ps1
2026-09-02 15:21:20 +08:00
ouyangxq 70d66c2fae 优化 2026-09-02 15:15:41 +08:00
ouyangxq b65f98451d 同步海外PDA柜级转换验收契约 2026-09-02 15:15:07 +08:00
ouyangxq e48e8dca42 fix(deploy): eliminate duplicate container log writes 2026-09-02 15:14:24 +08:00
ouyangxq 8da57487c0 修复一件代发多票事件幂等冲突 2026-09-02 15:04:15 +08:00
ouyangxq 3d81b22850 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
# Conflicts:
#	scripts/admin-test-env-modules/phase4-carrier-nodes.mjs
2026-09-02 14:46:15 +08:00
ouyangxq 19a6af1c6d 优化 2026-09-02 14:45:34 +08:00
ouyangxq 9ea4ccb9b1 优化 2026-09-02 14:39:28 +08:00
ouyangxq b6800a48c6 test(acceptance): follow automatic departure flow 2026-09-02 14:16:44 +08:00
ouyangxq afb38f3c3f fix(dispatch): expose PDA departure gate 2026-09-02 14:00:54 +08:00
ouyangxq 25ffd62e14 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java
#	firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java
#	scripts/admin-test-env-modules/ocean-bookings.mjs
#	scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-customs.mjs
#	scripts/admin-test-env-modules/phase3-loading-finalize.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
#	scripts/admin-test-env-modules/phase3-port-schedules.mjs
#	scripts/admin-test-env-modules/service-center-flow.mjs
2026-09-02 14:00:44 +08:00
ouyangxq 5d098c9762 test(acceptance): align retained state fixtures 2026-09-02 13:59:05 +08:00
ouyangxq f4c139815c 优化 2026-09-02 13:38:44 +08:00
ouyangxq 69ab3e9a90 优化 2026-09-02 13:34:53 +08:00
ouyangxq 68340eb616 test(acceptance): align current gateway contracts 2026-09-02 13:26:24 +08:00
ouyangxq f7f8ff53c0 test(acceptance): align automatic preload workflows 2026-09-02 13:03:03 +08:00
ouyangxq 275d4df35d fix(firstleg): make loading evidence upload replayable 2026-09-02 12:53:18 +08:00
ouyangxq e3fd48c3b9 fix(firstleg): restore smart loading candidate projection 2026-09-02 12:33:26 +08:00
ouyangxq a4d8fa9f8b fix(order): preserve submitted review status 2026-09-02 12:19:46 +08:00
ouyangxq 1574263836 fix(lastleg): validate dropship SKU for PDA outbound 2026-09-02 12:09:14 +08:00
ouyangxq 08be508501 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	order/AGENTS.md
#	scripts/agents-index-contract.ps1
2026-09-02 12:08:08 +08:00
ouyangxq 9e70f4339a fix(file): allow standalone legal-person ID upload 2026-09-02 12:07:32 +08:00
ouyangxq f0da6e5110 优化 2026-09-02 11:59:54 +08:00
ouyangxq d786b88030 fix(firstleg): map declaration inbound projection 2026-09-02 11:51:56 +08:00
ouyangxq 6acb5297e5 test(firstleg): lock internal declaration endpoints 2026-09-02 10:45:09 +08:00
ouyangxq bb9fb9ffe6 fix(firstleg): retain declaration event idempotency history 2026-09-02 10:44:17 +08:00
ouyangxq 8e7835fe86 优化 2026-09-02 10:41:32 +08:00
ouyangxq d0d0b2cdda Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-02 10:34:02 +08:00
ouyangxq 31996491be test(firstleg): bind channel change hash to full payload 2026-09-02 10:29:24 +08:00
ouyangxq e0a1f15777 优化 2026-09-02 10:27:47 +08:00
ouyangxq 371aa3dbcb 优化 2026-09-02 10:26:40 +08:00
ouyangxq 5f8b7baa06 优化 2026-09-02 10:26:37 +08:00
ouyangxq 37c37c75c8 优化 2026-09-02 10:26:34 +08:00
ouyangxq 70bbdd5b7b fix(auth): decouple managed login phones from company contacts 2026-09-02 10:11:11 +08:00
ouyangxq da56f261cd 优化 2026-09-02 10:05:56 +08:00
ouyangxq 8a66af81b1 优化 2026-09-02 10:04:54 +08:00
ouyangxq e83dff6ac7 优化 2026-09-02 10:04:18 +08:00
ouyangxq 79c390ad58 test: close phase five inventory snapshot coverage 2026-09-02 03:51:07 +08:00
ouyangxq a4a4306154 test: align phase five dev acceptance flows 2026-09-02 03:32:35 +08:00
ouyangxq f3be06a3e0 test: cover merchant phase 5 delivery 2026-09-02 02:51:58 +08:00
ouyangxq b5d7be7d28 fix: replay completed dispatch picks 2026-09-02 02:03:15 +08:00
ouyangxq 666add1cf9 fix: complete overseas PDA inventory ledgers 2026-09-02 01:47:00 +08:00
ouyangxq cc02037f78 fix: preserve dropship pallets in container plans 2026-09-02 01:17:00 +08:00
ouyangxq fd9a070efc fix: settle whole dropship pallets by sku 2026-09-02 00:27:04 +08:00
ouyangxq 7e6c7a4b9d test: preserve whole dropship pallets in producer flow 2026-09-02 00:16:53 +08:00
ouyangxq cd749733de fix: preserve dropship sku loading details 2026-09-02 00:01:39 +08:00
ouyangxq 571a5544a3 test: pick complete dropship pallet fixtures 2026-09-01 23:47:18 +08:00
ouyangxq fa7a7b8630 fix: enforce pickup-ready receiving candidates 2026-09-01 23:35:23 +08:00
ouyangxq f4a3b58959 fix: validate overseas handover before settlement 2026-09-01 23:20:34 +08:00
ouyangxq e19224c4b7 fix: make loading evidence replay idempotent 2026-09-01 22:59:50 +08:00
ouyangxq d965a82cfd test: accept automatic outbound confirmation 2026-09-01 22:54:02 +08:00
ouyangxq 7f9649f309 test: align outbound producer with container customs 2026-09-01 22:52:39 +08:00
ouyangxq 20eb524a51 test: complete dropship palletization before smart loading 2026-09-01 22:38:16 +08:00
ouyangxq bc596878d6 fix: keep order declaration schema in unified baseline 2026-09-01 22:30:15 +08:00
ouyangxq aa77cece23 fix: align smart loading candidate mapping 2026-09-01 22:09:01 +08:00
ouyangxq ad5e0812bf fix: restore phase five development acceptance chain 2026-09-01 21:55:27 +08:00
ouyangxq 04e0111756 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 21:51:52 +08:00
ouyangxq b85e021cbe test(order): cover declaration field clearing 2026-09-01 21:11:37 +08:00
ouyangxq e82cefe493 优化 2026-09-01 21:09:33 +08:00
ouyangxq 47723c7c0f feat(file): register domestic order exports 2026-09-01 21:03:57 +08:00
ouyangxq b9cb5555e2 fix(firstleg): isolate declarations from measurement facts 2026-09-01 20:59:30 +08:00
ouyangxq 858061452c feat(order): formalize domestic order export contract 2026-09-01 20:48:44 +08:00
ouyangxq 83f4e1a3de test: align loading tests with latest test contract 2026-09-01 20:47:27 +08:00
ouyangxq b2ec00754a Merge remote-tracking branch 'origin/test' into dev 2026-09-01 20:46:48 +08:00
ouyangxq 94c67b5c51 优化 2026-09-01 20:41:22 +08:00
ouyangxq 6449daf823 fix: allow optional phase5 custom admin role 2026-09-01 20:36:24 +08:00
ouyangxq 974d7b1e43 fix: complete phase5 overseas deployment contract 2026-09-01 20:20:25 +08:00
ouyangxq 8e17401f37 优化 2026-09-01 20:07:38 +08:00
ouyangxq 539030d2b6 fix(file): return controlled missing package errors 2026-09-01 20:01:14 +08:00
ouyangxq d88e5cffce fix: align phase5 overseas permissions 2026-09-01 20:00:49 +08:00
ouyangxq a5d787b279 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:59:24 +08:00
ouyangxq b4e496e9cb fix(file): support optional legal ID and order import files 2026-09-01 19:52:42 +08:00
ouyangxq 9fc20d5482 fix: keep ambiguous legacy phones unbound 2026-09-01 19:34:17 +08:00
ouyangxq 271a77fd86 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:32:20 +08:00
ouyangxq 2ec8fcb886 fix(order): route customer addresses through gateway 2026-09-01 19:32:01 +08:00
ouyangxq 405d6b94d7 fix(auth): reject placeholder mobile numbers 2026-09-01 19:27:46 +08:00
ouyangxq 176d146b8e fix: preserve merchant warehouse authorities overseas 2026-09-01 19:25:48 +08:00
ouyangxq 28526bf43e Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:25:16 +08:00
ouyangxq caffcb90a9 feat(order): add customer order foundations 2026-09-01 19:17:43 +08:00
ouyangxq 222c3790dc feat(auth): support username or phone login 2026-09-01 19:15:32 +08:00
ouyangxq b6e81bab6b test: align inbound status contract with dropship palletizing 2026-09-01 19:11:36 +08:00
ouyangxq 1c26f7db9b Merge remote-tracking branch 'origin/test' into dev 2026-09-01 19:00:36 +08:00
ouyangxq 0ba3df2fce test(firstleg): cover booking-level truck lifecycle 2026-09-01 16:36:41 +08:00
ouyangxq 9098d8dfdf test(firstleg): decouple PDA completion from truck 2026-09-01 16:33:57 +08:00
ouyangxq 20d0d84240 fix: preserve lastleg comments in overseas migration 2026-09-01 16:30:41 +08:00
ouyangxq eb385fe906 test(firstleg): align outbound truck lookup contract 2026-09-01 16:24:10 +08:00
ouyangxq ecc83ed64a fix(firstleg): scope truck appointments by booking 2026-09-01 16:21:27 +08:00
ouyangxq c319986738 fix: order lastleg delivery baseline for clean database 2026-09-01 16:18:38 +08:00
ouyangxq a8f8ff2b6d Merge remote-tracking branch 'origin/test' into dev 2026-09-01 15:59:15 +08:00
ouyangxq ca8127ce83 test(firstleg): formalize late container gate acceptance 2026-09-01 15:53:12 +08:00
ouyangxq e02c463be7 优化 2026-09-01 15:46:30 +08:00
ouyangxq aa828ca1eb 优化 2026-09-01 15:43:19 +08:00
ouyangxq df1ffbc6bc fix(firstleg): filter truck booking candidates 2026-09-01 15:41:36 +08:00
ouyangxq 8b2a964bfb fix(firstleg): allow late initial container fill 2026-09-01 15:38:33 +08:00
ouyangxq 07ffd8ddce fix(firstleg): finalize late container gate 2026-09-01 15:27:34 +08:00
ouyangxq d1225845ec 优化 2026-09-01 15:25:15 +08:00
ouyangxq 4d7ea28a61 feat(firstleg): capture container number at pickup 2026-09-01 15:18:18 +08:00
liuyihe 8a94c541cb Merge remote-tracking branch 'origin/dev' into dev 2026-09-01 15:14:24 +08:00
ouyangxq b223ce82d6 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 15:11:52 +08:00
ouyangxq 1e8f504f15 fix(firstleg): allow booking link before container pickup 2026-09-01 15:06:03 +08:00
ouyangxq 94395faeef fix: make phase 5 permission validation mysql compatible 2026-09-01 15:00:38 +08:00
ouyangxq 579c61879d fix: include phase 3 customs baseline in unified sql 2026-09-01 14:54:47 +08:00
ouyangxq 41837180f8 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:49:41 +08:00
ouyangxq a10e32a012 test(firstleg): finish task evidence migration 2026-09-01 14:48:51 +08:00
ouyangxq 980d37b9c2 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:48:33 +08:00
ouyangxq f2958bef6e 优化 2026-09-01 14:47:45 +08:00
ouyangxq 0e1ad4ed45 优化 2026-09-01 14:47:43 +08:00
ouyangxq 76d2da5bf9 test(firstleg): use task-scoped loading evidence 2026-09-01 14:46:48 +08:00
ouyangxq 46f5a73c47 优化 2026-09-01 14:46:44 +08:00
ouyangxq b610a128de Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 14:46:36 +08:00
ouyangxq f0ac0c42c9 test(firstleg): use valid PDF truck fixtures 2026-09-01 14:44:30 +08:00
ouyangxq 105660bd09 test(firstleg): scope loading evidence by cabinet 2026-09-01 14:37:02 +08:00
ouyangxq 2ccfc38cdf test(firstleg): seed booking file before loading release 2026-09-01 14:31:01 +08:00
ouyangxq f551cd8c7a 优化 2026-09-01 14:27:16 +08:00
ouyangxq d2092bf0b6 优化 2026-09-01 14:27:04 +08:00
ouyangxq 97168b19d8 feat: complete phase 5 overseas fulfillment contracts 2026-09-01 14:24:59 +08:00
liuyihe 8b0c99593e 一件代发拣货 2026-09-01 13:41:00 +08:00
ouyangxq 9e32efdd90 test(firstleg): harden measurement demo package 2026-09-01 12:03:21 +08:00
ouyangxq 6eaf484fcd test(firstleg): package carrier node demo checks 2026-09-01 12:02:57 +08:00
ouyangxq 53078b4281 test(firstleg): extend shipment demo regression 2026-09-01 12:02:47 +08:00
ouyangxq 49d216cc13 test(order): harden demo order lifecycle fixture 2026-09-01 12:02:35 +08:00
ouyangxq 9a3b408de0 优化 2026-09-01 11:38:42 +08:00
ouyangxq 68b584128c fix(firstleg): surface existing truck appointment 2026-09-01 11:30:35 +08:00
ouyangxq b5bc77b153 fix(firstleg): atomically finalize PDA completion 2026-09-01 11:11:43 +08:00
ouyangxq ffed9bf898 Merge branch 'dev-lyhfive' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 11:09:14 +08:00
ouyangxq de0eddeee8 修复尾程货主快照与短单号合同 2026-09-01 11:06:26 +08:00
ouyangxq 4991536065 test(firstleg): harden pallet option page contract 2026-09-01 11:03:40 +08:00
ouyangxq 7ace1ded56 优化 2026-09-01 11:02:10 +08:00
ouyangxq 5af79df4bd test(firstleg): align trucking attachment regressions 2026-09-01 11:01:34 +08:00
ouyangxq 6eb53751a3 test(pda): keep overseas acceptance business-scoped 2026-09-01 11:01:20 +08:00
ouyangxq 09e81b429c test(firstleg): align fulfillment matrix with container customs 2026-09-01 10:48:33 +08:00
ouyangxq 179a20fc38 refactor(firstleg): keep trucking payable only 2026-09-01 10:38:15 +08:00
ouyangxq 6a0f51f136 优化 2026-09-01 10:29:14 +08:00
ouyangxq 7d9eaa7f43 test(firstleg): verify trucking detail after dispatch 2026-09-01 10:26:22 +08:00
ouyangxq bfc2a4dbd4 test(firstleg): scope trucking warehouse lookup 2026-09-01 10:22:15 +08:00
ouyangxq 0613d1095e test(firstleg): resolve trucking fixtures from active catalogs 2026-09-01 10:21:37 +08:00
ouyangxq 3d7990250f fix(finance): align operating fund export labels 2026-09-01 10:19:35 +08:00
ouyangxq 2c3efad70a test: bound A0 location isolation writes 2026-09-01 10:18:19 +08:00
ouyangxq cdcf3e0f4f test(firstleg): parse platform warehouse scope explicitly 2026-09-01 10:17:41 +08:00
ouyangxq 3d6216910f test(finance): align full acceptance with current workflows 2026-09-01 10:17:03 +08:00
ouyangxq 505188c3fd test(firstleg): make trucking acceptance self-contained 2026-09-01 10:16:07 +08:00
ouyangxq 477971dda1 优化 2026-09-01 10:00:13 +08:00
ouyangxq 249b182e7f refactor(firstleg): remove customer scope from trucking 2026-09-01 09:51:59 +08:00
ouyangxq 936412f351 test(file-print): extend booking document regression 2026-08-31 20:56:52 +08:00
ouyangxq 0cc4eae209 feat(firstleg): enforce booking-owned truck workflow 2026-08-31 19:55:58 +08:00
ouyangxq f2a980e5c1 fix(file): allow arbitrary ocean booking documents 2026-08-31 19:54:05 +08:00
ouyangxq 47ce84d122 feat(file-print): support ocean booking documents 2026-08-31 19:30:37 +08:00
ouyangxq 01385c4bc7 优化 2026-08-31 18:55:03 +08:00
ouyangxq 9b09663b2e fix(firstleg): allow corrective evidence after completion failure 2026-08-31 18:42:08 +08:00
ouyangxq 838bd93bc4 test(firstleg): align cabinet evidence SQL contracts 2026-08-31 18:31:39 +08:00
ouyangxq 60d45613c7 fix(firstleg): import multipart request annotation 2026-08-31 18:24:58 +08:00
ouyangxq 4ddfc6fb99 chore(firstleg): remove legacy inbound evidence schema 2026-08-31 18:19:25 +08:00
ouyangxq df0fbc1c7f fix(firstleg): scope loading evidence to cabinet tasks 2026-08-31 18:14:36 +08:00
ouyangxq 4795ab958c fix(file): require Firstleg for PDA evidence access 2026-08-31 17:42:19 +08:00
ouyangxq 1288c8937c fix(file): bind loading evidence to cabinet 2026-08-31 17:33:11 +08:00
ouyangxq ba8e3595e4 fix(firstleg): explain coload group eligibility 2026-08-31 15:40:24 +08:00
ouyangxq 17d81b0b94 feat(order): expose unified order channel code 2026-08-31 15:38:18 +08:00
ouyangxq c9792b1f63 fix(firstleg): resolve loading list lifecycle by plan 2026-08-31 15:26:47 +08:00
ouyangxq 0122ad314b feat(firstleg): expose smart-loading channel directory 2026-08-31 14:54:00 +08:00
ouyangxq ea615b864a fix(firstleg): cancel coload groups by inbound code 2026-08-31 14:53:44 +08:00
ouyangxq 314ce09b5d fix(firstleg): load container overview transport history 2026-08-31 12:39:24 +08:00
ouyangxq 942dbace19 feat: enforce canonical container identity across regions 2026-08-31 12:13:20 +08:00
ouyangxq b386f35ab4 优化 2026-08-31 10:31:51 +08:00
ouyangxq d6f47e650c 优化 2026-08-31 10:19:48 +08:00
ouyangxq bef91151a3 优化 2026-08-31 10:17:42 +08:00
ouyangxq bf1dc1f209 优化 2026-08-31 10:17:03 +08:00
ouyangxq aa583efa4a 优化 2026-08-31 10:09:14 +08:00
ouyangxq ff6a8498a6 fix(lastleg): resolve actual overseas receiving warehouse 2026-08-30 18:07:38 +08:00
ouyangxq ca9e7b60e8 优化 2026-08-30 17:02:19 +08:00
ouyangxq 0aa77327d5 test(firstleg): stabilize coload smart and short-pick checks 2026-08-30 16:14:07 +08:00
ouyangxq 5c83b81e8a test(firstleg): harden coload capacity acceptance 2026-08-30 16:07:12 +08:00
ouyangxq c9ee319e9d fix(firstleg): refresh recommendation capacity 2026-08-30 16:06:20 +08:00
ouyangxq 4581c8a294 test(firstleg): assert atomic smart coload recommendation 2026-08-30 15:58:14 +08:00
ouyangxq 9a15e78b2e test(firstleg): extend coload group acceptance 2026-08-30 15:55:48 +08:00
ouyangxq bfcb80b326 fix(firstleg): align coload group eligibility 2026-08-29 12:44:42 +08:00
ouyangxq 0cf76df168 fix(firstleg): align coload loading route gates 2026-08-29 12:36:07 +08:00
ouyangxq ba641754de test(firstleg): document coload PDA acceptance 2026-08-29 11:59:53 +08:00
ouyangxq 81ef43c9fd 优化 2026-08-29 11:59:11 +08:00
ouyangxq 0605c32829 fix(firstleg): persist pricing conversion metadata 2026-08-29 11:48:19 +08:00
ouyangxq 66816d5341 优化 2026-08-29 11:42:52 +08:00
ouyangxq de101621dd fix(firstleg): align merchant billing with sent fees 2026-08-29 11:13:35 +08:00
ouyangxq b4db37392d fix(firstleg): complete coload SQL comments 2026-08-29 11:13:10 +08:00
ouyangxq b1ab909184 优化 2026-08-29 11:10:28 +08:00
ouyangxq 775dd71227 fix(firstleg): keep published billing visible after loading 2026-08-29 11:00:19 +08:00
ouyangxq 3339ec91f9 feat(firstleg): enforce indivisible coload loading 2026-08-29 10:59:38 +08:00
ouyangxq ac6d3289a6 feat(firstleg): add indivisible coload groups 2026-08-29 10:51:50 +08:00
ouyangxq e6ee9cb442 fix(firstleg): recover multi-customer PDA completion 2026-08-29 08:25:42 +08:00
ouyangxq da32a67d7f 优化 2026-08-28 23:01:52 +08:00
ouyangxq 969ff05815 test(firstleg): correct mixed customs completion probe 2026-08-28 22:54:23 +08:00
ouyangxq 7f565a24d6 test(firstleg): fix mixed customs permission probe 2026-08-28 22:50:55 +08:00
ouyangxq 1e389d95dd fix(firstleg): serialize concurrent customs start replay 2026-08-28 22:44:37 +08:00
ouyangxq f53ff76143 test(firstleg): add mixed container customs acceptance 2026-08-28 22:39:18 +08:00
ouyangxq f87647d4eb fix(firstleg): allow customs uploads up to 50MB 2026-08-28 22:15:24 +08:00
ouyangxq 833f928a30 fix(file): align customs download permission contract 2026-08-28 22:12:29 +08:00
ouyangxq 621aec9b2b fix(firstleg): replay completed customs uploads 2026-08-28 21:42:51 +08:00
ouyangxq f72238e43f fix(firstleg): map customs document versions explicitly 2026-08-28 21:20:43 +08:00
ouyangxq 7a15481e2d fix(firstleg): resolve merchant customs plan lookup 2026-08-28 17:28:39 +08:00
ouyangxq 2dca7dfdb2 优化 2026-08-28 17:10:12 +08:00
ouyangxq 1c51373704 fix(firstleg): register container customs mapper 2026-08-28 17:10:07 +08:00
ouyangxq 9664aa4bf0 test(firstleg): align customs contract assertions 2026-08-28 16:59:43 +08:00
ouyangxq 603bd29cc2 优化 2026-08-28 16:54:21 +08:00
ouyangxq 0afe75379e test(order): align batch draft service mocks 2026-08-28 16:53:28 +08:00
ouyangxq 0ea4aeecc9 feat(firstleg): implement container customs workflow 2026-08-28 16:48:50 +08:00
ouyangxq bb9673358d 优化 2026-08-28 13:58:49 +08:00
ouyangxq 6b5f2f0565 优化 2026-08-28 13:54:32 +08:00
ouyangxq fe68551124 优化 2026-08-28 12:33:14 +08:00
ouyangxq f998bed09c 优化 2026-08-28 11:28:30 +08:00
ouyangxq 1e5ce54858 优化 2026-08-28 10:38:58 +08:00
ouyangxq b34fbb2bd1 fix(ci): ignore stale Surefire reports 2026-08-28 10:32:41 +08:00
ouyangxq a6f266687e 优化 2026-08-28 10:03:32 +08:00
ouyangxq dd117b8fc2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-28 09:19:19 +08:00
ouyangxq a719a903cd 优化 2026-08-28 09:19:18 +08:00
wujinliang 3b1b4ab9b2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-27 21:49:00 +08:00
wujinliang 9d54c57b7c 1 2026-08-27 21:48:47 +08:00
ouyangxq e72ac14425 fix: map warehouse pallet lookup failure 2026-08-27 19:31:38 +08:00
ouyangxq ec8f84c1c0 fix: reject missing receiving pallet 2026-08-27 19:27:59 +08:00
ouyangxq 2b7f66cb04 fix: reclaim newly added container plans 2026-08-27 19:14:54 +08:00
ouyangxq c7061b8a74 优化 2026-08-27 18:18:14 +08:00
ouyangxq 5250f304be 优化 2026-08-27 18:11:57 +08:00
ouyangxq f5f72d681d 优化 2026-08-27 18:05:02 +08:00
ouyangxq c226b6a99b 优化 2026-08-27 17:49:02 +08:00
ouyangxq 600b2a2dc4 优化 2026-08-27 17:31:19 +08:00
ouyangxq 76c24867a4 优化 2026-08-27 17:16:33 +08:00
ouyangxq 61a97cd057 优化 2026-08-27 16:42:27 +08:00
ouyangxq ecf48886d5 优化 2026-08-27 16:34:30 +08:00
ouyangxq 1db3e48506 优化 2026-08-27 16:17:11 +08:00
ouyangxq 0f2e201c38 fix(lastleg): unblock inbound plan handoff 2026-08-27 15:56:14 +08:00
ouyangxq adabec13d3 fix(print): include Spring DAO exceptions at runtime 2026-08-27 15:21:28 +08:00
ouyangxq 0fb51211aa 优化 2026-08-27 15:02:37 +08:00
ouyangxq d7bff980ca 优化 2026-08-27 14:53:27 +08:00
ouyangxq ed3c1dafcc 优化 2026-08-27 14:30:33 +08:00
ouyangxq 0a3ec0b8a6 优化 2026-08-27 14:30:23 +08:00
ouyangxq 3b38fdd33d 优化 2026-08-27 13:50:33 +08:00
ouyangxq daf0ee2acd 优化 2026-08-27 11:45:52 +08:00
ouyangxq 086a0f9c89 优化 2026-08-27 11:42:05 +08:00
ouyangxq c80307a838 优化 2026-08-27 11:39:32 +08:00
ouyangxq 575cee7a86 优化 2026-08-27 11:38:53 +08:00
ouyangxq c1b136c3d6 优化 2026-08-27 11:09:51 +08:00
ouyangxq 4612181ab0 优化 2026-08-27 11:07:57 +08:00
ouyangxq b4c46280de test(acceptance): cover booking agent carrier matrix 2026-08-27 10:38:55 +08:00
ouyangxq 15b563610c fix(firstleg): validate booking carrier associations 2026-08-27 10:30:38 +08:00
ouyangxq 079ff43f73 feat(warehouse): support supplier carrier associations 2026-08-27 10:29:28 +08:00
ouyangxq 554b6c93b7 优化 2026-08-27 09:21:01 +08:00
ouyangxq 817e356c21 Merge remote-tracking branch 'origin/dev' into test 2026-08-26 21:17:25 +08:00
ouyangxq ca12f58d38 chore: remove tracked runtime artifacts 2026-08-26 20:51:42 +08:00
ouyangxq 8fb63e9adc Merge remote-tracking branch 'origin/dev' into test
# Conflicts:
#	warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java
#	warehouse/src/main/resources/db/warehouse.sql
2026-08-26 20:51:27 +08:00
wujinliang d736fdba1b 1 2026-08-26 18:12:55 +08:00
3695 changed files with 328820 additions and 48576 deletions
+2
View File
@@ -14,6 +14,7 @@ logs/
data/ data/
.env .env
.env.* .env.*
.env.development
!.env.example !.env.example
!.env.*.example !.env.*.example
application-local.yml application-local.yml
@@ -28,5 +29,6 @@ reports/
uploads/ uploads/
.codex-temp* .codex-temp*
.codex-tmp* .codex-tmp*
.runtime-*/
.codex-*.out .codex-*.out
.codex-*.err .codex-*.err
-2
View File
@@ -89,7 +89,6 @@ OpenAPI有效性:
expire_in: 7 days expire_in: 7 days
paths: paths:
- gateway/target/*.jar - gateway/target/*.jar
- gateway-overseas/target/*.jar
- auth/target/*.jar - auth/target/*.jar
- iam/target/*.jar - iam/target/*.jar
- order/target/*.jar - order/target/*.jar
@@ -102,4 +101,3 @@ OpenAPI有效性:
- print/target/*.jar - print/target/*.jar
- audit-service/target/*.jar - audit-service/target/*.jar
- integration-service/target/*.jar - integration-service/target/*.jar
- event-bridge-service/target/*.jar
Binary file not shown.
-39
View File
@@ -1,39 +0,0 @@
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
public final class DbQuery {
public static void main(String[] args) throws Exception {
if (args.length < 2) {
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
}
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
+ "&useSSL=false&allowPublicKeyRetrieval=true";
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
Statement statement = connection.createStatement()) {
boolean hasResult = statement.execute(args[1]);
if (!hasResult) {
System.out.println("updateCount=" + statement.getUpdateCount());
return;
}
try (ResultSet result = statement.getResultSet()) {
ResultSetMetaData meta = result.getMetaData();
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(meta.getColumnLabel(column));
}
System.out.println();
while (result.next()) {
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(String.valueOf(result.getObject(column)));
}
System.out.println();
}
}
}
}
}
Binary file not shown.
+108 -51
View File
@@ -1,53 +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`。 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `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` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `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。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位) ## 服务路由(按关键词定位)
| 任务关键词 | 首选服务 | 先看入口 | 本表只保留定位入口;命中业务后用 `rg -n -F '关键词' docs/backend-business-index.md` 读取对应行/段的详细约束,再沿代码引用追踪;不默认全文加载详细索引。已知目标文件时直接定位,并定向核对该业务约束。
|---|---|---|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | ### 后端任务最小读取集
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | - 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | - 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方及受影响的 OpenAPI/事件契约;公开路径、访问域或入口鉴权变化才增加 Gateway,内部调用和事件不经公网 Gateway。不因业务关键词自动加载整个业务专题。
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | - 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | - 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | 任务关键词 | 首选服务 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | |---|---|
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | | 登录、Token、密码、注册与顶层授权快照 | `auth/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | | 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | | 消息中心、站内通知、消息模板、收件箱 | `iam/` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` | | 订单、货物、快照、状态、幂等、Outbox | `order/` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` | | 客户头程订单中文批量导入 | `order/` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` | | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` | | 平台订单批量审核预览 | `order/` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 客户专属价格簿、柜级阶梯运费与受控调价 | `firstleg/` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | Firstleg 内部模块化与依赖基线 | `firstleg/` |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` |
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` |
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | 客户入库结果与头程费用确认 | `firstleg/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | | 目的港码头拖车与还空预警 | `firstleg/` |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `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/` |
## 公共模块快速索引 ## 公共模块快速索引
@@ -63,45 +97,68 @@
## 全量验收路由 ## 全量验收路由
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。 - 当前订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务统一进入全量验收;按本次链路读取对应业务域/专题文档,不以历史规划终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。 - `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同 ## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 - 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 - `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 生产代码的关系型数据库读写统一经 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 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 平台后台存在正式列表页时,`*: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` 必须停用/移除,不得重新提升或兼容。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 权限语义变更必须同步 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/数据库,除非用户明确要求。 - 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`。实际部署仍须当前对话明确授权。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由 ## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 - 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Client 快速下单定向验收使用 `scripts/client-quick-order-test-env-acceptance.mjs`,只验证批量草稿接口、逐行隔离和幂等行为,不提交、不审核,也不替代 Client 全量入口。
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。
- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。
- 这些脚本会创建、更新或最终停用测试数据;指定测试环境和服务后,本轮非破坏性写入复用一次授权,删除、重置、SQL、部署或发布仍逐项授权,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
## 阶段文档入口 ## 业务域文档入口
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md` - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 - 国内货柜履约:`../docs/国内货柜履约/README.md`,报关规则变化再查 `../docs/国内货柜履约/报关资料与船期价格接入梳理.md`。规划、确认与验收材料按归档位置及文档角色判断,当前入口指定的有效规则仍按需核对,历史材料只用于追溯或证据任务
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 全量验收需要证据时再读取对应阶段报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+124 -87
View File
@@ -1,11 +1,10 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
] ]
def serviceCheckboxes = [ def serviceCheckboxes = [
'gateway': 'SERVICE_GATEWAY', 'gateway': 'SERVICE_GATEWAY',
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
'auth': 'SERVICE_AUTH', 'auth': 'SERVICE_AUTH',
'iam': 'SERVICE_IAM', 'iam': 'SERVICE_IAM',
'firstleg': 'SERVICE_FIRSTLEG', 'firstleg': 'SERVICE_FIRSTLEG',
@@ -17,20 +16,19 @@ def serviceCheckboxes = [
'integration-service': 'SERVICE_INTEGRATION', 'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG', 'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT' 'audit-service': 'SERVICE_AUDIT'
] ]
def fullRebuildPaths = [ def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', '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 = [ def commonPathServices = [
'common-web/': [ 'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service' 'audit-service'
], ],
'common-security/': [ 'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
@@ -39,35 +37,36 @@ def commonPathServices = [
'common-openapi/': [ 'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service' 'audit-service'
], ],
'common-audit/': [ 'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service' 'warehouse-overseas-service', 'audit-service'
], ],
'common-event-contract/': [ 'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' 'firstleg', 'finance', 'lastleg-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
], ],
'deploy/docker-compose.audit.yml': ['audit-service'], 'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
] ]
def databaseMigrationPaths = ['deploy/sql/'] def databaseMigrationPaths = ['deploy/sql/']
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
def databaseSchemaContractEnabled = false
def downstreamResults = [:] def downstreamResults = [:]
def downstreamBuildUrls = [:]
def downstreamBuildNumbers = [:]
def parseAutoBuildState(String description, List<String> allowedServices) { def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS' if (!description?.startsWith('AUTO ')) return null
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null int markerIndex = description.indexOf(' | ')
int markerIndex = description.indexOf(marker) if (markerIndex < 0) return null
String commit = description.substring('AUTO '.length(), markerIndex).trim() String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim() String state = description.substring(markerIndex + ' | '.length()).trim()
List<String> failedServices = [] List<String> failedServices = []
if (summary) { if (state.startsWith('RESULTS')) {
String summary = state.substring('RESULTS'.length()).trim()
summary.split(',').collect { it.trim() }.findAll { it }.each { item -> summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=') int separator = item.indexOf('=')
if (separator > 0) { if (separator > 0) {
@@ -76,6 +75,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
} }
} }
} else if (state.startsWith('PENDING')) {
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
String selected = state.substring('PENDING'.length()).trim()
int suffixIndex = selected.indexOf(' | ')
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
failedServices.addAll(selected.split(',').collect { it.trim() }
.findAll { allowedServices.contains(it) })
} else {
return null
} }
return [commit: commit, failedServices: failedServices.unique()] return [commit: commit, failedServices: failedServices.unique()]
} }
@@ -85,7 +95,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
int inspected = 0 int inspected = 0
while (candidate != null && inspected < 30) { while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices) def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state // 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
if (state != null) return state
candidate = candidate.previousBuild candidate = candidate.previousBuild
inspected++ inspected++
} }
@@ -93,15 +104,33 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
} }
def environmentChoicesForJob(String jobBaseName) { def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev' return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
? ['development', 'test', 'staging', 'production']
: ['test', 'development', 'staging', 'production']
} }
def downstreamJobPrefixForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev' switch (jobBaseName) {
? 'baiyue-firstleg-dev-' case 'backend-java':
: 'baiyue-firstleg-' return 'test'
case 'backend-java-prod':
return 'production'
default:
return null
}
}
def fixedDownstreamJobPrefixForJob(String jobBaseName) {
switch (jobBaseName) {
case 'backend-java':
return 'baiyue-firstleg-'
case 'backend-java-prod':
return 'baiyue-firstleg-prod-'
default:
return null
}
}
def downstreamJobPrefixChoicesForJob(String jobBaseName) {
return [fixedDownstreamJobPrefixForJob(jobBaseName) ?: 'baiyue-firstleg-']
} }
pipeline { pipeline {
@@ -117,9 +146,8 @@ pipeline {
parameters { parameters {
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') 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', 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_AUTH', defaultValue: false, description: '统一认证服务(auth)')
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)')
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
@@ -129,15 +157,13 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-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)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址') string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
} }
stages { stages {
@@ -145,36 +171,41 @@ pipeline {
agent { label 'java-builder' } agent { label 'java-builder' }
steps { steps {
script { script {
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。 String resolvedEnvironment = fixedEnvironmentForJob(env.JOB_BASE_NAME)
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。 String resolvedJobPrefix = fixedDownstreamJobPrefixForJob(env.JOB_BASE_NAME)
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev' if (!resolvedEnvironment || !resolvedJobPrefix) {
? 'development' error("未配置总控 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
: params.ENVIRONMENT }
if (params.ENVIRONMENT != resolvedEnvironment ||
params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) {
error('部署环境和下游前缀必须与当前 Job 固定映射一致')
}
env.RESOLVED_ENVIRONMENT = resolvedEnvironment env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
// 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
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() String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { if (!requestedRepository) {
error('必须填写真实镜像仓库地址') error('必须填写真实镜像仓库地址')
} }
if (requestedRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
}
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
} }
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}" echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() env.RESOLVED_JOB_PREFIX = resolvedJobPrefix
if (!jobPrefix) {
error('DOWNSTREAM_JOB_PREFIX 不能为空')
}
env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false' env.DATABASE_CHANGE_DETECTED = 'false'
if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) {
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 = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
selected = serviceModules selected = serviceModules
@@ -190,9 +221,10 @@ pipeline {
String base = autoState?.commit?.trim() String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: [] List retryServices = autoState?.failedServices ?: []
if (!base) { if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录, // 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
// SELECT/ALL 不会推进 AUTO 基线。 // 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
} }
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) { if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
selected = serviceModules selected = serviceModules
@@ -234,19 +266,7 @@ pipeline {
} }
selected = selected.unique() 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.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) { if (!selected) {
@@ -269,14 +289,35 @@ pipeline {
} }
} }
stage('生产环境部署确认(仅 production 执行)') { stage('Gateway 路由静态门禁') {
when { when {
allOf { expression { env.NO_SERVICE_CHANGE != 'true' }
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
} }
agent { label 'java-builder' }
steps { steps {
input message: "确认将 ${env.SELECTED_SERVICES} 的镜像 ${env.IMAGE_TAG} 分别部署到生产环境?", ok: '确认部署' sh 'node --test scripts/gateway-route-contract.test.mjs'
}
}
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
'''
}
}
} }
} }
@@ -293,10 +334,15 @@ pipeline {
.findAll { it } .findAll { it }
.join(',') .join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """ lock(resource: 'firstleg-test-maven') {
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" timeout(time: 50, unit: 'MINUTES') {
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test 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};下游单服务任务不再重复执行测试" echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
} }
} }
@@ -316,14 +362,6 @@ pipeline {
String service = selectedService String service = selectedService
branches[service] = { branches[service] = {
String jobName = "${env.RESOLVED_JOB_PREFIX}${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}" echo "触发单服务 Job: ${jobName}"
def downstreamBuild = build( def downstreamBuild = build(
job: jobName, job: jobName,
@@ -331,19 +369,18 @@ pipeline {
propagate: false, propagate: false,
parameters: [ parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), 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), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY), booleanParam(name: 'DEPLOY', value: params.DEPLOY),
// 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
booleanParam(name: 'SKIP_TESTS', value: true), booleanParam(name: 'SKIP_TESTS', value: true),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE)
// Choice 参数始终传递所有新旧 Job 都接受的单区值;
// 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。
string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion)
] ]
) )
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})" echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
} }
} }
+75 -105
View File
@@ -1,11 +1,10 @@
def serviceModules = [ def serviceModules = [
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service'
] ]
def serviceJars = [ def serviceJars = [
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', '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', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
@@ -17,10 +16,21 @@ def serviceJars = [
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', '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', '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' 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
] ]
def fixedEnvironmentForServiceJob(String jobBaseName) {
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
}
def environmentChoicesForServiceJob(String jobBaseName) {
return [fixedEnvironmentForServiceJob(jobBaseName) ?: 'test']
}
pipeline { pipeline {
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。 // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent none agent none
@@ -35,18 +45,17 @@ pipeline {
parameters { parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境')
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
} }
environment { environment {
REGISTRY_CREDENTIALS = 'gitea-registry-token' REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
} }
stages { stages {
@@ -65,6 +74,7 @@ pipeline {
String sourceCommit = params.SOURCE_COMMIT?.trim() String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim() String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim() String imageRepository = params.IMAGE_REPOSITORY?.trim()
String resolvedEnvironment = fixedEnvironmentForServiceJob(env.JOB_BASE_NAME)
if (!sourceCommit) { if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
@@ -76,10 +86,11 @@ pipeline {
if (!serviceModules.contains(service)) { if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
} }
boolean deployBothBridges = service == 'event-bridge-service' && if (!resolvedEnvironment) {
imageTag?.endsWith('-both') error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续")
if (deployBothBridges) { }
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) if (params.ENVIRONMENT != resolvedEnvironment) {
error('部署环境必须与当前单服务 Job 固定映射一致')
} }
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
@@ -90,26 +101,20 @@ pipeline {
if (!imageRepository || imageRepository.contains('://')) { if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀') error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
} }
if (imageRepository.tokenize('/')[0] ==~ /(?i)(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?::[0-9]+)?/) {
error('IMAGE_REPOSITORY 不能使用 example 示例域名,请填写已核实的镜像仓库地址')
}
if (!imageRepository.contains('/')) { if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
} }
env.RESOLVED_SERVICE = service env.RESOLVED_SERVICE = service
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository env.RESOLVED_IMAGE_REPOSITORY = imageRepository
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(params.ENVIRONMENT)) {
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
}
}
} }
sh ''' sh '''
@@ -119,10 +124,18 @@ pipeline {
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || {
echo "灰度发布提交必须已经合并到 main" >&2
exit 1
}
fi
''' '''
script { script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}" currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}" echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
} }
} }
@@ -132,17 +145,17 @@ pipeline {
steps { steps {
script { script {
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') boolean skipTestExecution = params.SKIP_TESTS
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : '' String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) { if (params.SKIP_TESTS && coordinatorPinnedCommit) {
echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包"
} else if (params.ENVIRONMENT == 'test') {
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 { post {
@@ -185,99 +198,56 @@ pipeline {
} }
} }
stage('部署开发或测试环境') { stage('部署 TEST') {
when { when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } }
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
}
agent none agent none
steps { steps {
script { script {
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" node('java-test') {
String credentialId = "firstleg-${params.ENVIRONMENT}-env" deleteDir()
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' unstash 'deployment-files'
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 withCredentials([
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'),
lock(resource: serviceDeployResource, inversePrecedence: true) { usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { ]) {
node(targetLabel) { withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) {
deleteDir() sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"'
try {
unstash 'deployment-files'
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.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 '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
} finally {
cleanWs(deleteDirs: true, notFailBuild: true)
}
} }
} }
}
} }
} }
} }
} }
stage('部署单个服务') { stage('部署临时灰度') {
when { when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } }
beforeAgent true agent none
expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) }
}
agent {
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
}
steps { steps {
deleteDir()
unstash 'deployment-files'
script { script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env" lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] node('java-builder') {
boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' && deleteDir()
env.NODE_LABELS?.tokenize()?.contains('java-dev') unstash 'deployment-files'
withCredentials([
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'),
try { usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
withEnv([ string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
"DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}", file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) { ]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { withEnv(['TARGET_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST,
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" "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"'
} }
} }
} finally {
sh "docker logout ${registryHost} || true"
} }
} }
} }
} }
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
} }
} }
post { post {
+25 -8
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。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
## 模块 ## 模块
- `gateway`:四端统一 API 入口。
- `finance`:财务账单、费用、充值、授信与资金核销。
- `auth`:统一登录、令牌签发与刷新。 - `auth`:统一登录、令牌签发与刷新。
- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
- `order`:订单主数据及订单状态编排。 - `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。 - `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。 - `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:统一管理仓库内公共依赖版本。 - `platform-bom`:统一管理仓库内公共依赖版本。
@@ -27,15 +30,27 @@ mvn test
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 唯一发布配置为 `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`。真实凭据不得提交;本地编译不代表另有开发环境。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 ## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖:
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|---|---|---:|---:|---|
| 测试 | `json-file` | `50m` | `3` | 不要求 |
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
## 网关跨域配置 ## 网关跨域配置
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: 本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
```text ```text
CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
@@ -45,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验收。
+7 -1
View File
@@ -1,7 +1,13 @@
# Audit Service 模块指引 # 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`,不得增加子域扫描配置。
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。 - 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。 - 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
@@ -16,7 +22,7 @@
| 入库编排 | `service/AuditIngestService.java` | | 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
| 表结构 | `src/main/resources/db/audit.sql` | | 表结构 | `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> <groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@@ -1,17 +1,16 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling
@Import(MybatisPlusConfiguration.class) @Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
public class BaiyueAuditApplication { public class BaiyueAuditApplication {
@@ -1,10 +1,13 @@
package com.baiyue.audit.mapper; package com.baiyue.audit.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.entity.AuditLogEntity;
/** /**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。 * 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/ */
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> { public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
} }
@@ -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: spring:
profiles: profiles:
active: ${SPRING_PROFILES_ACTIVE:dev} active: ${SPRING_PROFILES_ACTIVE:test}
application: application:
name: audit-service name: audit-service
config: config:
import: import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
cloud: cloud:
nacos: nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME:nacos} username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD}
discovery: discovery:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
enabled: ${NACOS_DISCOVERY_ENABLED:true} enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
config: config:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
baiyue.audit.enabled: false baiyue.audit.enabled: false
baiyue: baiyue:
@@ -40,14 +40,3 @@ baiyue:
rocketmq: rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -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()));
}
}
+16 -6
View File
@@ -1,10 +1,16 @@
# Auth Service 模块指引 # 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、退出、密码和账号归属范围。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 - 客户主档、成员、角色权限归 `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;不得引入租户管理或切换。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口 ## 模块入口
@@ -13,10 +19,11 @@
|---|---| |---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | | 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 | | 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 |
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | 统一会话权威校验 | `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` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` | | 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -26,10 +33,13 @@
## 修改约束 ## 修改约束
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、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 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
## 全量验收导航 ## 全量验收导航
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。 订单与客户、入库测量与计费、国内货柜履约、海外仓业务及财务全量验收均复用本模块认证能力;按本次链路读取对应业务域/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史规划边界排除其他消费者。
+1
View File
@@ -22,6 +22,7 @@
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency> <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.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>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> </dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build> <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project> </project>
@@ -1,5 +1,6 @@
package com.baiyue.auth; package com.baiyue.auth;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
/** /**
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。 * 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
*/ */
@MapperScan("com.baiyue.auth.mapper") @MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.auth.client") @EnableFeignClients(basePackages = "com.baiyue.auth.client")
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueAuthApplication { public class BaiyueAuthApplication {
@@ -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 Set<String> permissions;
private Long permissionVersion; private Long permissionVersion;
private Long overseasWarehouseId; 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, public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions, String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) { Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, 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 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
*/ */
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", @FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class,
url = "${baiyue.clients.iam.url:}", path = "/internal/iam") url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamAuthorizationClient { public interface IamAuthorizationClient {
@PostMapping("/authorization-snapshot") @PostMapping("/authorization-snapshot")
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
@@ -16,13 +17,18 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request); @RequestBody OnboardingRequest request);
@GetMapping("/merchant-registrations/{authUserId}")
ApiResponse<OnboardingResponse> registration(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId);
@DeleteMapping("/merchant-registrations/{authUserId}/compensation") @DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate( ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId); @PathVariable("authUserId") Long authUserId);
record OnboardingRequest(Long authUserId, String displayName, String companyName, record OnboardingRequest(Long authUserId, String displayName, String companyName,
String legalPerson, String legalPhone, String contactEmail, String legalPerson, String administratorPhone, String contactEmail,
String countryCode, String address) { } String countryCode, String address) { }
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/ */
@ConfigurationProperties(prefix = "baiyue.security") @ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, public record AuthSecurityProperties(String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl, Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) { 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.JWKSet;
import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OverseasJwtProperties.class) @EnableConfigurationProperties(UnifiedJwtProperties.class)
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") public class UnifiedJwtConfiguration {
public class OverseasJwtConfiguration { @Bean("jwtEncoder")
@Bean("overseasJwtEncoder") JwtEncoder jwtEncoder(UnifiedJwtProperties properties) {
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
try { try {
JWKSet set = JWKSet.parse(properties.rsaJwkSet()); JWKSet set = JWKSet.parse(properties.rsaJwkSet());
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null; RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
if (current == null || !current.isPrivate()) { 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))) { 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()) if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) { && jwk instanceof RSAKey rsa && rsa.isPrivate())) {
throw new IllegalStateException("海外非当前密钥只能配置公钥"); throw new IllegalStateException("统一非当前密钥只能配置公钥");
} }
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) { } 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.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos; import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
/** /**
@@ -40,19 +38,6 @@ public class AuthController {
private MerchantRegistrationService registrationService; private MerchantRegistrationService registrationService;
@Resource @Resource
private AccountManagementService accountManagementService; private AccountManagementService accountManagementService;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
@AuthenticationPrincipal AuthenticatedUser user) {
OverseasTokenService service = overseasTokens.getIfAvailable();
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
return ApiResponse.success(service.exchange(user.userId()));
}
@PutMapping("/password") @PutMapping("/password")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前用户密码") @Operation(summary = "修改当前用户密码")
@@ -77,7 +62,7 @@ public class AuthController {
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.") description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) { public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
return ApiResponse.success(registrationService.register(request)); return ApiResponse.success(registrationService.register(request));
} }
@@ -3,15 +3,12 @@ package com.baiyue.auth.controller;
import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.dto.AccountManagementDtos.*;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.AuthService; import com.baiyue.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.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -22,14 +19,13 @@ import java.security.MessageDigest;
/** 仅供可信内部服务调用的账号管理接口。 */ /** 仅供可信内部服务调用的账号管理接口。 */
@RestController @RestController
@RequestMapping("/internal/auth/accounts") @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 { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource @Resource
private AuthService authService; private AuthService authService;
@Resource @Resource private com.baiyue.auth.service.SessionStateService sessionStateService;
private OverseasSessionValidationService overseasSessionValidationService;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String configuredToken; private String configuredToken;
@@ -71,20 +67,36 @@ public class InternalAccountController {
@Valid @RequestBody RevokeAccountSessionsRequest request) { @Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
} }
@PostMapping("/overseas-session/validate") public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId,
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", @jakarta.validation.constraints.NotNull Long userId) { }
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession( @PostMapping("/sessions/validate")
public ApiResponse<Boolean> validateSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) { @Valid @RequestBody SessionValidationRequest request) {
verify(token); verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request)); return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId()));
} }
@PutMapping("/{id}/profile") @PutMapping("/{id}/profile")
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null); verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
} }
@PutMapping("/{id}/login-identifiers")
public ApiResponse<AccountSummary> loginIdentifiers(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
verify(token);
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
}
@PutMapping("/{id}/phone")
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
verify(token);
service.updatePhone(id, request);
return ApiResponse.success(null);
}
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -1,10 +1,9 @@
package com.baiyue.auth.controller; package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties; import com.baiyue.auth.config.UnifiedJwtProperties;
import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.JWKSet;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.CacheControl; import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; 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.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ /** 发布统一 JWT 的 RSA 公钥。 */
@RestController @RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController { 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") @GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements @SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") @Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
public ResponseEntity<Map<String, Object>> jwks() throws Exception { public ResponseEntity<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { } String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId, public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId, @NotBlank String displayName, @NotNull Long tenantId,
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
String accountStatus) { String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName, public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) { Long tenantId, Long membershipId, String appCode) {
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE"); this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
} }
} }
public record ChangeLoginIdentifiersRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@NotBlank String currentPassword) { }
public record UpdateAccountPhoneRequest(
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest( public record RevokeAccountSessionsRequest(
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
public record ImpersonationExchangeRequest(@NotBlank String code) { } public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status, public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) { LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) { public AccountSummary(Long accountId, String username, String displayName, String status) {
this(accountId, username, displayName, status, null, null, null, null); this(accountId, username, null, displayName, status, null, null, null, null);
} }
} }
} }
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** /**
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。 * 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
*/ */
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据") @Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
public record LoginRequest( public record LoginRequest(
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username, @Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
@NotBlank @Size(max = 64) String loginId,
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password @Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
) { ) {
} }
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration") @Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
public record MerchantRegisterRequest( public record MerchantRegisterRequest(
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username, @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 72) String password, @NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName, @NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName, @NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson, @Size(max = 64) String legalPerson,
@NotBlank @Size(max = 32) String legalPhone, @Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail, @Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode, @Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address @Size(max = 255) String address
@@ -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
}
}
@@ -2,6 +2,8 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Set;
/** /**
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。 * 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
*/ */
@@ -12,9 +14,11 @@ public record TokenResponse(
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", @Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set<String> roles,
format = "password", nullable = true) String overseasAccessToken, @Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn
) { ) {
public TokenResponse {
roles = roles == null ? Set.of() : Set.copyOf(roles);
authorities = authorities == null ? Set.of() : Set.copyOf(authorities);
}
} }
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
public class AuthAccount { public class AuthAccount {
@TableId private Long id; @TableId private Long id;
private String username; private String username;
private String phone;
private String passwordHash; private String passwordHash;
private String displayName; private String displayName;
private String accountStatus; private String accountStatus;
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。 * 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { } public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.entity.AuthRefreshToken;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。 * 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { } public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
@@ -19,7 +19,13 @@ public class AuthAccountRepository {
public AuthAccount findByUsername(String username) { public AuthAccount findByUsername(String username) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>() return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username) .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 FOR UPDATE"));
} }
public boolean existsByUsername(String username) { public boolean existsByUsername(String username) {
@@ -27,16 +33,35 @@ public class AuthAccountRepository {
.eq(AuthAccount::getUsername, username)) > 0; .eq(AuthAccount::getUsername, username)) > 0;
} }
public boolean existsByPhone(String phone) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)) > 0;
}
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)
.ne(AuthAccount::getId, accountId)) > 0;
}
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.ne(AuthAccount::getId, accountId)) > 0;
}
public void insert(AuthAccount account) { mapper.insert(account); } public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) { public AuthAccount findById(Long id) {
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) { public List<AuthAccount> findMerchantByTenant(Long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>() return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getDefaultTenantId, tenantId) .eq(AuthAccount::getDefaultTenantId, tenantId)
.eq(AuthAccount::getDefaultAppCode, "MERCHANT")); .eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE"));
} }
public void save(AuthAccount account) { public void save(AuthAccount account) {
@@ -9,6 +9,7 @@ import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale; import java.util.Locale;
@@ -63,7 +64,9 @@ public class AccountManagementService {
String appCode = request.appCode() == null ? "MERCHANT" String appCode = request.appCode() == null ? "MERCHANT"
: request.appCode().trim().toUpperCase(Locale.ROOT); : request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId()); 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 (existing != null) {
if (sameIdentity(existing, request, appCode)) return summary(existing); if (sameIdentity(existing, request, appCode)) return summary(existing);
throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用"); throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用");
@@ -71,9 +74,13 @@ public class AccountManagementService {
if (repository.existsByUsername(request.username())) { if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在"); throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
} }
if (request.phone() != null && repository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(request.accountId()); account.setId(request.accountId());
account.setUsername(request.username()); account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password())); account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus()); account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
@@ -84,7 +91,13 @@ public class AccountManagementService {
account.setDefaultMembershipId(request.membershipId()); account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode(appCode); account.setDefaultAppCode(appCode);
account.setDeleted(0); 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); return summary(account);
} }
@@ -104,7 +117,7 @@ public class AccountManagementService {
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false) captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) { public void revokeSessions(Set<Long> accountIds) {
accountIds.forEach(accountId -> bumpVersion(required(accountId))); accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId)));
} }
@Transactional @Transactional
@@ -117,6 +130,45 @@ public class AccountManagementService {
bumpVersion(account); bumpVersion(account);
} }
@Transactional
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改本人用户名和手机号", captureAfter = false)
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
AuthAccount account = required(accountId);
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
}
String username = request.username().trim();
String phone = request.phone().trim();
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
return summary(account);
}
account.setUsername(username);
account.setPhone(phone);
bumpVersion(account);
return summary(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步受管账号手机号", captureAfter = false)
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
AuthAccount account = required(accountId);
String phone = request.phone() == null ? null : request.phone().trim();
if (Objects.equals(account.getPhone(), phone)) return;
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
account.setPhone(phone);
bumpVersion(account);
}
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) { public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
@@ -185,7 +237,7 @@ public class AccountManagementService {
} }
private AccountSummary summary(AuthAccount a) { private AccountSummary summary(AuthAccount a) {
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(), return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode()); a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
} }
@@ -201,6 +253,7 @@ public class AccountManagementService {
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) { private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username()) return Objects.equals(account.getUsername(), request.username())
&& Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId()) && Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId()) && Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId()) && Objects.equals(account.getDefaultMembershipId(), request.membershipId())
@@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider; import com.baiyue.auth.config.UnifiedJwtProperties;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
@@ -51,16 +51,19 @@ public class AuthService {
@Resource @Resource
private TokenSessionStore tokenSessionStore; private TokenSessionStore tokenSessionStore;
@Resource @Resource
private ObjectProvider<OverseasTokenService> overseasTokens; private UnifiedJwtProperties signingProperties;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@Transactional @Transactional
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username", @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false) description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) { public TokenResponse login(LoginRequest request) {
AuthAccount account = accountRepository.findByUsername(request.username()); String loginId = request.loginId().trim();
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
? accountRepository.findByPhone(loginId)
: accountRepository.findByUsername(loginId);
if (account == null) throw invalidCredentials(); if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus())) if (!"ACTIVE".equals(account.getAccountStatus()))
@@ -89,7 +92,8 @@ public class AuthService {
AuthAccount account = accountRepository.findById(session.userId()); AuthAccount account = accountRepository.findById(session.userId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
return issue(account); if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态");
return issue(account, session.fingerprint());
} }
@Transactional @Transactional
@@ -106,8 +110,13 @@ public class AuthService {
accountRepository.save(account); 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); AuthorizationSnapshot authorization = authorizationLoader.load(account);
if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) {
throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录");
}
Instant issuedAt = Instant.now(); Instant issuedAt = Instant.now();
String jwtId = UUID.randomUUID().toString(); String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
@@ -132,6 +141,9 @@ public class AuthService {
if (authorization.getOverseasWarehouseId() != null) { if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
} }
if (authorization.getDomesticWarehouseId() != null) {
claimsBuilder.claim("domestic_warehouse_id", authorization.getDomesticWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build(); JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue(); signingHeader(), claims)).getTokenValue();
@@ -139,15 +151,12 @@ public class AuthService {
byte[] random = new byte[48]; byte[] random = new byte[48];
secureRandom.nextBytes(random); secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(),
SessionStateService.fingerprint(account, authorization), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl()); 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(), return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(), authorization.getRoles(), authorization.getPermissions());
overseas == null ? 0L : overseas.expiresIn());
} }
@Transactional @Transactional
@@ -200,18 +209,26 @@ public class AuthService {
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15)); authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900); return new ImpersonationTokenResponse("Bearer", accessToken, 900,
authorization.getRoles(), authorization.getPermissions());
} }
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { } public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn,
java.util.Set<String> roles,
java.util.Set<String> authorities) {
public ImpersonationTokenResponse {
roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles);
authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities);
}
}
private JwsHeader signingHeader() { private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build(); return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build();
} }
private static BizException invalidCredentials() { private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
} }
} }
@@ -35,8 +35,20 @@ public class MerchantRegistrationService {
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT", @BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
resourceId = "#request.username", description = "商户注册", captureAfter = false) resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) { public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) { AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
throw new BizException(ResultCode.CONFLICT, "Username already exists"); AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
if (usernameAccount != null && phoneAccount != null
&& usernameAccount.getId().equals(phoneAccount.getId())
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
return existingRegistration(usernameAccount);
}
if (usernameAccount != null) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (phoneAccount != null) {
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
} }
long accountId = IdWorker.getId(); long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password()); String passwordHash = passwordEncoder.encode(request.password());
@@ -46,7 +58,7 @@ public class MerchantRegistrationService {
try { try {
var response = onboardingClient.register(internalToken, var response = onboardingClient.register(internalToken,
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(), new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
request.companyName(), request.legalPerson(), request.legalPhone(), request.companyName(), request.legalPerson(), request.phone(),
request.contactEmail(), request.countryCode(), request.address())); request.contactEmail(), request.countryCode(), request.address()));
if (response == null) { if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
@@ -81,6 +93,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(accountId); account.setId(accountId);
account.setUsername(request.username()); account.setUsername(request.username());
account.setPhone(request.phone());
account.setPasswordHash(passwordHash); account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE"); account.setAccountStatus("ACTIVE");
@@ -94,7 +107,7 @@ public class MerchantRegistrationService {
try { try {
accountRepository.insert(account); accountRepository.insert(account);
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists"); BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
failure.addSuppressed(exception); failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted); compensateOnboarding(accountId, failure, compensationCompleted);
throw failure; throw failure;
@@ -107,6 +120,24 @@ public class MerchantRegistrationService {
"LOGIN_AND_COMPLETE_QUALIFICATIONS"); "LOGIN_AND_COMPLETE_QUALIFICATIONS");
} }
private MerchantRegisterResponse existingRegistration(AuthAccount account) {
try {
var response = onboardingClient.registration(internalToken, account.getId());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
var onboarding = response.data();
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
}
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
}
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return; return;
@@ -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,82 +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.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:",
"platform:overseas-business:",
"platform:overseas-master-data:",
"lastleg:"
);
@Resource private AuthAccountRepository accounts;
@Resource private 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("roles", authorization.getRoles())
.claim("authorities", overseasAuthorities(authorization.getPermissions()))
.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());
}
private Set<String> overseasAuthorities(Set<String> permissions) {
if (permissions == null || permissions.isEmpty()) return Set.of();
return permissions.stream()
.filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream().anyMatch(permission::startsWith))
.collect(Collectors.toUnmodifiableSet());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
@@ -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.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.SetOperations; 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 org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -21,8 +19,6 @@ import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Set; import java.util.Set;
import java.util.ArrayList;
import java.util.List;
/** /**
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
@@ -42,7 +38,7 @@ public class TokenSessionStore {
} }
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions, 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 accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId); String userKey = userSessionsKey(userId);
@@ -50,25 +46,27 @@ public class TokenSessionStore {
String accessValue = objectMapper.writeValueAsString( String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions))); new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); 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); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet(); 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); redisTemplate.expire(userKey, refreshTtl);
} catch (Exception exception) { } 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); throw unavailable(exception);
} }
} }
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles, public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) { Set<String> permissions, String fingerprint, Duration ttl) {
try { try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String accessValue = objectMapper.writeValueAsString( String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions))); new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, ttl); 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); redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) { } catch (Exception exception) {
throw unavailable(exception); throw unavailable(exception);
@@ -103,6 +101,7 @@ public class TokenSessionStore {
if (value == null || value.isBlank()) return null; if (value == null || value.isBlank()) return null;
RefreshSession session = objectMapper.readValue(value, RefreshSession.class); RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
redisTemplate.delete(stateKey(session.accessJwtId()));
return session; return session;
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw unavailable(exception); throw unavailable(exception);
@@ -121,43 +120,26 @@ public class TokenSessionStore {
try { try {
Set<String> keys = redisTemplate.opsForSet().members(userKey); Set<String> keys = redisTemplate.opsForSet().members(userKey);
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); 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); redisTemplate.delete(userKey);
} catch (DataAccessException exception) { } catch (DataAccessException exception) {
throw unavailable(exception); throw unavailable(exception);
} }
} }
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) { private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; }
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
if (cursor == null) return; private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception {
try (cursor) { if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state");
while (cursor.hasNext()) { redisTemplate.opsForValue().set(stateKey(jwtId),
String key = cursor.next(); objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl);
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 boolean belongsTo(String value, Long userId) { public SessionState loadState(String jwtId) {
if (value == null || value.isBlank()) return false; try {
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null;
catch (Exception ignored) { return false; } String value = redisTemplate.opsForValue().get(stateKey(jwtId));
} return value == null ? null : objectMapper.readValue(value, SessionState.class);
} catch (Exception failure) { throw unavailable(failure); }
private boolean accessBelongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
catch (Exception ignored) { return false; }
} }
private static Set<String> normalized(Set<String> values) { 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 record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession { public AccessSession {
roles = normalized(roles); 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}}
+17 -28
View File
@@ -2,36 +2,35 @@ server:
port: ${AUTH_PORT:9100} port: ${AUTH_PORT:9100}
spring: spring:
profiles: profiles:
active: ${SPRING_PROFILES_ACTIVE:dev} active: ${SPRING_PROFILES_ACTIVE:test}
application: application:
name: auth-service name: auth-service
config: config:
import: import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
data: data:
redis: redis:
host: ${REDIS_HOST:127.0.0.1} host: ${REDIS_HOST}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:} password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0} database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s} timeout: ${REDIS_TIMEOUT:3s}
cloud: cloud:
nacos: nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME:nacos} username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD}
discovery: discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true} enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE} group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
config: config:
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE}
encode: UTF-8 encode: UTF-8
baiyue.security: baiyue.security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api} audience: ${JWT_AUDIENCE:lastleg-api}
@@ -39,13 +38,12 @@ baiyue.security:
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5} max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m} lock-duration: ${LOGIN_LOCK_DURATION:15m}
overseas-jwt: jwt-mode: jwks
enabled: ${OVERSEAS_JWT_ENABLED:false} session-mode: redis
issuer: ${OVERSEAS_JWT_ISSUER:auth-service} jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} jwt:
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} rsa-jwk-set: ${JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:} current-kid: ${JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
baiyue.clients: baiyue.clients:
iam: iam:
@@ -53,13 +51,4 @@ baiyue.clients:
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
logging: baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate}
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,46 @@
-- Auth 单一登录标识增量:用户名或中国大陆手机号。
-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。
USE baiyue_auth;
DROP PROCEDURE IF EXISTS auth_add_login_phone;
DELIMITER $$
CREATE PROCEDURE auth_add_login_phone()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN
ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username;
END IF;
END$$
DELIMITER ;
CALL auth_add_login_phone();
DROP PROCEDURE auth_add_login_phone;
DROP PROCEDURE IF EXISTS auth_backfill_login_phone;
DELIMITER $$
CREATE PROCEDURE auth_backfill_login_phone()
BEGIN
UPDATE auth_account
SET phone = NULL
WHERE phone IS NOT NULL AND phone NOT REGEXP '^1[3-9][0-9]{9}$';
UPDATE auth_account account
JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
JOIN (
SELECT phone
FROM baiyue_iam.iam_user
WHERE deleted = 0 AND phone REGEXP '^1[3-9][0-9]{9}$'
GROUP BY phone
HAVING COUNT(DISTINCT auth_user_id) = 1
) unique_phone ON unique_phone.phone = user.phone
SET account.phone = user.phone
WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$';
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
AND index_name = 'uk_auth_account_phone') THEN
ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
END IF;
END$$
DELIMITER ;
CALL auth_backfill_login_phone();
DROP PROCEDURE auth_backfill_login_phone;
+2
View File
@@ -6,6 +6,7 @@ USE baiyue_auth;
CREATE TABLE IF NOT EXISTS auth_account ( CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)', id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定', account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username), UNIQUE KEY uk_auth_account_username (username),
UNIQUE KEY uk_auth_account_phone (phone),
KEY idx_auth_account_status (account_status, locked_until) KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态'; ) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
@@ -0,0 +1,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 { class AuthTokenTtlConfigurationContractTest {
@Test @Test
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception { void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception {
String configuration = Files.readString( 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) assertThat(configuration)
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}") .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") .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}"); .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; 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.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.KeyUse;
@@ -20,8 +20,7 @@ class JwksControllerTest {
RSAKey current = key("current", true); RSAKey current = key("current", true);
RSAKey previous = key("previous", false); RSAKey previous = key("previous", false);
String json = new JWKSet(List.of(current, previous)).toString(false); String json = new JWKSet(List.of(current, previous)).toString(false);
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json, var properties = new UnifiedJwtProperties(json, "current");
"current", Duration.ofMinutes(15));
var body = new JwksController(properties).jwks().getBody(); var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); 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");
}
}
@@ -0,0 +1,43 @@
package com.baiyue.auth.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class AuthLoginIdentifiersSqlContractTest {
@Test
void legacyMigrationBackfillsOnlyUniquelyOwnedPhonesAndKeepsDuplicatesUnbound() throws Exception {
assertSafeBackfill(new ClassPathResource("db/auth-login-identifiers.sql")
.getContentAsString(StandardCharsets.UTF_8));
}
@Test
void emptyDatabaseInitializationDeclaresPhoneUniquenessWithoutLegacyBackfill() throws Exception {
String sql = Files.readString(unifiedSql(), StandardCharsets.UTF_8);
assertThat(sql)
.contains("phone varchar(32) DEFAULT NULL")
.contains("UNIQUE KEY uk_auth_account_phone (phone)")
.doesNotContain("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
.doesNotContain("HAVING COUNT(DISTINCT auth_user_id) = 1")
.doesNotContain("ALTER TABLE auth_account");
}
private static void assertSafeBackfill(String sql) {
assertThat(sql)
.contains("phone NOT REGEXP '^1[3-9][0-9]{9}$'")
.contains("HAVING COUNT(DISTINCT auth_user_id) = 1")
.contains("ADD UNIQUE KEY uk_auth_account_phone (phone)")
.doesNotContain("IAM 存在重复手机号,禁止建立 Auth 唯一登录索引");
}
private static Path unifiedSql() {
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -19,8 +19,8 @@ class InitialAccountSeedSqlContractTest {
private static void assertAccountSeed(String sql) { private static void assertAccountSeed(String sql) {
assertThat(sql) assertThat(sql)
.contains("(1000001, 'platform_admin'") .containsPattern("\\(1000001\\s*,\\s*'platform_admin'")
.contains("'平台系统管理员', 'ACTIVE'") .containsPattern("'平台系统管理员'\\s*,\\s*'ACTIVE'")
.contains("password_change_required") .contains("password_change_required")
.doesNotContain("merchant_admin") .doesNotContain("merchant_admin")
.doesNotContain("IT_TEST") .doesNotContain("IT_TEST")
@@ -0,0 +1,48 @@
package com.baiyue.auth.repository;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.dto.MerchantRegisterRequest;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class LoginIdentifierContractTest {
@Test
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
.map(component -> component.getName())
.toList();
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
}
@Test
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var invalidRegistration = new MerchantRegisterRequest(
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
"11111111111", null, "CN", null);
var validChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "13800138000", "Password1");
var invalidChange = new ChangeLoginIdentifiersRequest(
"merchant.user", "11111111111", "Password1");
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
assertThat(validator.validate(validChange)).isEmpty();
assertThat(validator.validate(invalidChange)).isNotEmpty();
}
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("REGEXP '^1[3-9][0-9]{9}$'")
.doesNotContain("REGEXP '^1[0-9]{10}$'");
}
}
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -18,6 +20,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.dao.DuplicateKeyException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -83,7 +86,7 @@ class AccountManagementServiceTest {
} }
@Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() {
when(repository.findById(20L)).thenReturn(null); when(repository.findCurrentById(20L)).thenReturn(null);
when(repository.existsByUsername("platform.user")).thenReturn(false); when(repository.existsByUsername("platform.user")).thenReturn(false);
var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1", var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1",
@@ -95,6 +98,7 @@ class AccountManagementServiceTest {
assertEquals("PLATFORM", captor.getValue().getDefaultAppCode()); assertEquals("PLATFORM", captor.getValue().getDefaultAppCode());
assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash())); assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash()));
assertNotEquals("Password1", captor.getValue().getPasswordHash()); assertNotEquals("Password1", captor.getValue().getPasswordHash());
verify(repository, never()).findById(20L);
} }
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
@@ -105,7 +109,7 @@ class AccountManagementServiceTest {
} }
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
when(repository.findById(20L)).thenReturn(null); when(repository.findCurrentById(20L)).thenReturn(null);
when(repository.existsByUsername("merchant.user")).thenReturn(false); when(repository.existsByUsername("merchant.user")).thenReturn(false);
var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1",
@@ -139,6 +143,7 @@ class AccountManagementServiceTest {
account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setUsername("platform.user"); account.setDisplayName("平台用户");
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); 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", var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1",
"平台用户", 0L, 30L, "PLATFORM")); "平台用户", 0L, 30L, "PLATFORM"));
@@ -147,6 +152,44 @@ class AccountManagementServiceTest {
verify(repository, never()).insert(any()); 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() { @Test void synchronizeDisplayNameRevokesExistingSessions() {
account.setDisplayName("旧名称"); account.setDisplayName("旧名称");
@@ -157,6 +200,44 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L); verify(sessions).revokeAll(10L);
} }
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
var result = service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
assertEquals("new.user", result.username());
assertEquals("13900139000", result.phone());
assertEquals(3, account.getTokenVersion());
verify(sessions).revokeAll(10L);
}
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
account.setPhone("13800138000");
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
assertEquals(null, account.getPhone());
assertEquals(3, account.getTokenVersion());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
account.setUsername("old.user");
account.setPhone("13800138000");
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() { @Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount(); AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE"); second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
@@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -37,21 +37,24 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder; @MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader; @MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore; @MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service; @Resource private AuthService service;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
static class TestConfig { static class TestConfig {
@Bean
com.baiyue.auth.config.UnifiedJwtProperties signingProperties() {
return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current");
}
@Bean @Bean
AuthSecurityProperties properties() { AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", return new AuthSecurityProperties("auth-service", "lastleg-api",
Duration.ofMinutes(30), Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10)); Duration.ofDays(7), 5, Duration.ofMinutes(10));
} }
} }
@Test @Test
void signsCrossRegionClaimsAndStoresSession() { void signsUnifiedClaimsAndStoresSession() {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(42L); account.setId(42L);
account.setUsername("operator"); account.setUsername("operator");
@@ -63,7 +66,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM"); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1); account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); "WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account); when(repository.findByUsername("operator")).thenReturn(account);
@@ -71,8 +74,6 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token"); when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
var response = service.login(new LoginRequest("operator", "secret")); var response = service.login(new LoginRequest("operator", "secret"));
@@ -88,20 +89,59 @@ class AuthServiceSmallJwtTest {
.containsEntry("overseas_warehouse_id", 900L) .containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope"); .doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current");
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); assertThat(response.authorities()).containsExactly("platform:order:list");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
} }
@Test @Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
account.setPhone("13800138000");
account.setPasswordHash("hash");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(0L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(84L);
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of(), Set.of(), 1L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByPhone("13800138000")).thenReturn(account);
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
service.login(new LoginRequest("13800138000", "secret"));
verify(repository).findByPhone("13800138000");
verify(repository, never()).findByUsername("13800138000");
}
@Test
void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() {
when(repository.findByUsername("11111111111")).thenReturn(null);
assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret")))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("登录标识或密码错误");
verify(repository).findByUsername("11111111111");
verify(repository, never()).findByPhone("11111111111");
}
@Test
void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(52L); account.setId(52L);
account.setUsername("merchant"); account.setUsername("merchant");
@@ -116,13 +156,11 @@ class AuthServiceSmallJwtTest {
Jwt encoded = org.mockito.Mockito.mock(Jwt.class); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn( 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(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
var response = service.refresh("refresh-old"); var response = service.refresh("refresh-old");
@@ -131,17 +169,16 @@ class AuthServiceSmallJwtTest {
assertThat(response.expiresIn()).isEqualTo(1800); assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800); assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); assertThat(response.roles()).containsExactly("MERCHANT_ADMIN");
assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.authorities()).containsExactly("merchant:order:list");
verify(tokenSessionStore).consume("refresh-old"); verify(tokenSessionStore).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), 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.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account); verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
} }
@Test @Test
@@ -154,7 +191,6 @@ class AuthServiceSmallJwtTest {
verify(repository, never()).findById(any()); verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any()); verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
} }
@Test @Test
@@ -180,7 +216,10 @@ class AuthServiceSmallJwtTest {
when(jwtEncoder.encode(any())).thenReturn(encoded); when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
service.exchangeImpersonationTicket("one-time-code"); AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code");
assertThat(response.roles()).containsExactly("MERCHANT_OWNER");
assertThat(response.authorities()).containsExactly("merchant:order:list");
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture()); verify(jwtEncoder).encode(jwt.capture());
@@ -192,6 +231,6 @@ class AuthServiceSmallJwtTest {
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
} }
} }
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
@SpringJUnitConfig(MerchantRegistrationService.class) @SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@@ -54,11 +55,56 @@ class MerchantRegistrationServiceTest {
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId()); assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode()); assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("13800138000", storedAccount.getValue().getPhone());
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus()); assertEquals("PENDING", result.dimensionInitializationStatus());
} }
@Test
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
account.setPasswordHash("encoded-password");
account.setAccountStatus("ACTIVE");
account.setDefaultAppCode("MERCHANT");
when(accounts.findByUsername("registered_owner")).thenReturn(account);
when(accounts.findByPhone("13800138000")).thenReturn(account);
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
assertEquals(9001L, result.accountId());
assertEquals(10001L, result.tenantId());
assertEquals("READY", result.dimensionInitializationStatus());
verify(onboarding, never()).register(eq("internal-token"), any());
verify(accounts, never()).insert(any());
}
@Test
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
when(accounts.findByPhone("13800138000")).thenReturn(account);
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.register(new MerchantRegisterRequest(
"another_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
verify(onboarding, never()).register(eq("internal-token"), any());
}
@Test @Test
void failedAuthInsertCompensatesTheIamRegistration() { void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password"); when(encoder.encode("Password123")).thenReturn("encoded-password");
@@ -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("WAREHOUSE_PICKER"), 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,132 +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 lastlegAuthority = "lastleg:pda:operate";
String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
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)
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
}
@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("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
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("WAREHOUSE_PICKER"), 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); when(redis.opsForValue()).thenReturn(values);
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), 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"), verify(values).set(eq("baiyue:auth:access:jti-1"),
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
@@ -64,7 +64,7 @@ class TokenSessionStoreTest {
ValueOperations<String, String> values = mock(ValueOperations.class); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
String json = new ObjectMapper().writeValueAsString( String json = new ObjectMapper().writeValueAsString(
new TokenSessionStore.RefreshSession(42L, "jti-1")); new TokenSessionStore.RefreshSession(42L, "jti-1", "state"));
when(values.getAndDelete(anyString())).thenReturn(json); when(values.getAndDelete(anyString())).thenReturn(json);
TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引 # Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。 - 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/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.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 审计 Outbox 的 RocketMQ 发布配置。 * 审计 Outbox 的 RocketMQ 发布配置。
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。 * 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
*/ */
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class) @AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
@EnableScheduling
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
@ConditionalOnBean(type = { @ConditionalOnBean(type = {
"com.baiyue.common.audit.AuditOutboxRepository", "com.baiyue.common.audit.AuditOutboxRepository",
+9 -1
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引 # Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。 - 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
@@ -14,11 +19,14 @@
| 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` | | 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` |
| 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` |
| MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | | 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` | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` |
## 修改与验证 ## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 - 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约。当前正式模型不保留旧序列化或数据库兼容分支;若已有不可变外部契约,先单独定义升级规则。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。 - 新能力只有被多个模块稳定复用时才放入本模块。
+3
View File
@@ -14,6 +14,7 @@
<name>baiyue-common-core</name> <name>baiyue-common-core</name>
<dependencies> <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>org.springframework</groupId><artifactId>spring-web</artifactId></dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
@@ -24,5 +25,7 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> </dependencies>
</project> </project>
@@ -1,5 +1,7 @@
package com.baiyue.common.core.api; package com.baiyue.common.core.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant; import java.time.Instant;
/** /**
@@ -8,7 +10,7 @@ import java.time.Instant;
public record ApiResponse<T>( public record ApiResponse<T>(
String code, String code,
String message, String message,
T data, @JsonInclude(JsonInclude.Include.ALWAYS) T data,
String requestId, String requestId,
String traceId, String traceId,
Instant timestamp) { Instant timestamp) {
@@ -0,0 +1,56 @@
package com.baiyue.common.core.container;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** Platform-wide container types and practical loading limits. */
public final class ContainerSpecificationCatalog {
public static final String DEFAULT_CONTAINER_TYPE = "40HQ";
private static final List<Specification> ORDERED_VALUES = List.of(
new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"),
"美线电商较少使用,通常优先选择40HQ"),
new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"),
"轻泡货首选,适合家居、玩具、日用品"),
new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"),
"抛货量大时考虑,底盘和海外仓预约资源较少")
);
private static final Map<String, Specification> VALUES = ORDERED_VALUES.stream()
.collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity()));
private ContainerSpecificationCatalog() {}
public static List<Specification> values() {
return ORDERED_VALUES;
}
public static Specification require(String containerType) {
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
Specification specification = VALUES.get(normalized);
if (specification == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ");
}
return specification;
}
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
Specification specification = require(containerType);
if (capacityCbm == null || maxWeightKg == null
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"柜型容量和货重上限必须使用平台统一定义");
}
return specification;
}
public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm,
BigDecimal maxWeightKg, String description) {}
}
@@ -0,0 +1,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");
}
}
@@ -0,0 +1,33 @@
package com.baiyue.common.core.container;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerSpecificationCatalogTest {
@Test
void exposesTheThreeCanonicalContainerTypesInBusinessOrder() {
assertThat(ContainerSpecificationCatalog.values())
.extracting(ContainerSpecificationCatalog.Specification::containerType)
.containsExactly("40GP", "40HQ", "45HQ");
assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ");
}
@Test
void exposesPracticalLoadingRangesAndRejectsRemovedTypes() {
var specification = ContainerSpecificationCatalog.requireMatch(
"40hq", new BigDecimal("68.000"), new BigDecimal("19500.000"));
assertThat(specification.containerType()).isEqualTo("40HQ");
assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65");
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC"))
.isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ");
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
"40HQ", new BigDecimal("76"), new BigDecimal("19500")))
.isInstanceOf(BizException.class).hasMessageContaining("统一定义");
}
}
+18 -11
View File
@@ -1,9 +1,14 @@
# Common Event Contract 模块指引 # Common Event Contract 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界 ## 职责边界
- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。 - 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。 - 只放事件信封、类型、标识、规范化、哈希和校验;不放部署区域、领域 Entity、数据库模型或发送实现。
## 快速索引 ## 快速索引
@@ -13,21 +18,23 @@
| 契约校验 | `EventEnvelopeValidator.java` | | 契约校验 | `EventEnvelopeValidator.java` |
| 事件 ID 与业务键 | `EventIdentifiers.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | | 事件类型与直接目标 | `EventTypes.java`、`EventDeliveryRoute.java`;明确白名单,不按区域或默认目标分派 |
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 |
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 |
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 国内船节点记录 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/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 |
| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
## 修改与验证 ## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 - 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - 业务事件不携带用户 Access Token;直接投递使用内部服务身份,信封保留事件ID、业务类型/版本、客户、聚合、追踪及Payload哈希,不再包含sourceRegion/targetRegion。
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 - `OVERSEAS_CONTAINER_UNLOADED` 固定由Lastleg Outbox直投Firstleg,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间规则沿用现业务实现,实际报空时间允许后续补充。
- 从父目录运行 `mvn -pl common-event-contract -am test`。 - 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -0,0 +1,62 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/**
* 跨区域业务费用来源的完整不可变快照。
*
* <p>海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。
* 标识统一使用字符串,避免跨语言大整数精度损失。</p>
*/
public record BusinessFeeSourceChangedV1(
String action,
String sourceSystem,
String sourceType,
String sourceFeeId,
int sourceVersion,
String customerId,
String customerName,
String sourceNo,
String businessType,
String businessId,
String businessNo,
String orderId,
String inboundId,
String inboundCode,
String containerId,
String containerBookingId,
String containerNo,
String warehouseId,
String feeItemCode,
String feeItemName,
String direction,
String supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
Instant paidAt,
String paymentMethod,
String paymentReferenceNo,
List<String> paymentVoucherFileIds,
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String originalCurrency,
BigDecimal originalAmount,
BigDecimal exchangeRate,
String exchangeRateMonth,
BigDecimal chargeableQuantity,
String quantityUnit,
BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
String reviewStatus,
String publicationStatus,
String sourceStatus,
String sourceHash,
WalletSettlementReference walletSettlement
) {
}
@@ -0,0 +1,44 @@
package com.baiyue.common.event;
import java.util.Locale;
/** Canonical physical container number used by cross-region business facts. */
public final class ContainerNumbers {
private static final int[] LETTER_VALUES = {
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
};
private ContainerNumbers() { }
public static String normalizeRequired(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
StringBuilder compact = new StringBuilder(value.length());
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
String normalized = compact.toString().toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
}
int sum = 0;
for (int index = 0; index < 10; index++) {
char current = normalized.charAt(index);
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
sum += number * (1 << index);
}
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
}
return normalized;
}
public static String requireCanonical(String value) {
String normalized = normalizeRequired(value);
if (!normalized.equals(value)) {
throw new IllegalArgumentException("containerNo must be normalized");
}
return normalized;
}
}
@@ -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; import java.time.Instant;
/** /**
* 跨服务和跨区域传输使用的不可变事件信封。 * 统一测试环境中跨业务服务传输使用的不可变事件信封。
* *
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 * <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p> * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
@@ -14,8 +14,6 @@ import java.time.Instant;
* @param eventType 事件类型,不包含版本号 * @param eventType 事件类型,不包含版本号
* @param eventVersion 事件契约版本,从 1 开始 * @param eventVersion 事件契约版本,从 1 开始
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
* @param sourceRegion 事件来源业务单元
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
* @param tenantId 事件所属客户租户 ID * @param tenantId 事件所属客户租户 ID
* @param aggregateType 聚合类型 * @param aggregateType 聚合类型
* @param aggregateId 聚合 ID * @param aggregateId 聚合 ID
@@ -32,8 +30,6 @@ public record EventEnvelope(
String eventType, String eventType,
int eventVersion, int eventVersion,
Instant occurredAt, Instant occurredAt,
RegionCode sourceRegion,
RegionCode targetRegion,
String tenantId, String tenantId,
String aggregateType, String aggregateType,
String aggregateId, String aggregateId,
@@ -37,12 +37,6 @@ public final class EventEnvelopeValidator {
if (envelope.occurredAt() == null) { if (envelope.occurredAt() == null) {
errors.add("occurredAt 不能为空"); 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); requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
if (envelope.aggregateType() == null if (envelope.aggregateType() == null
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
@@ -75,7 +69,7 @@ public final class EventEnvelopeValidator {
public static void requireValid(EventEnvelope envelope) { public static void requireValid(EventEnvelope envelope) {
List<String> errors = validate(envelope); List<String> errors = validate(envelope);
if (!errors.isEmpty()) { 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); }
}
@@ -14,9 +14,11 @@ public final class EventTypes {
"FirstLegContainerShippingNodeRecorded"; "FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; 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 LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
@@ -1,14 +1,14 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ /** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
public record FirstLegContainerShippingNodeRecordedV1( public record FirstLegContainerShippingNodeRecordedV1(
String queryId, String queryId,
String customerId,
String oceanBookingId, String oceanBookingId,
String containerBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String containerNo,
String standardNode, String standardNode,
int nodeSequence, int nodeSequence,
@@ -28,12 +28,17 @@ public record FirstLegContainerShippingNodeRecordedV1(
"EMPTY_RETURNED", 100); "EMPTY_RETURNED", 100);
public FirstLegContainerShippingNodeRecordedV1 { public FirstLegContainerShippingNodeRecordedV1 {
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() if (queryId != null && queryId.isBlank()) {
|| oceanBookingId == null || oceanBookingId.isBlank() throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串");
|| containerBookingId == null || containerBookingId.isBlank() }
if (oceanBookingId != null && oceanBookingId.isBlank()) {
throw new IllegalArgumentException("船节点订舱标识不能为空字符串");
}
if (containerBookingId == null || containerBookingId.isBlank()
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
} }
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) { || observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1( public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { List<Order> orders) {
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets, BigDecimal volumeWeightKg,
String destinationType) { List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType, String lxCode) {
public Order { public Order {
items = items == null ? List.of() : items; items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, null);
} }
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,27 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
} }
} }
@@ -1,13 +1,38 @@
package com.baiyue.common.event; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */ /** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
public record FirstLegLoadingPlanCompletedV2( public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { List<Order> orders) {
public FirstLegLoadingPlanCompletedV2 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
Order owner = orders.get(0);
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|| !owner.customerName().equals(order.customerName()))) {
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
}
}
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets, BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) { String destinationType, String lxCode) {
public Order { public Order {
items = items == null ? List.of() : items; items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
customerId = customerId.trim();
customerName = customerName.trim();
if (customerId.length() > 128 || customerName.length() > 128) {
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null, null);
} }
public Order(String sourceInboundId, String rootInboundId, public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +82,27 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) { List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
BigDecimal volumeWeightKg, List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
packageCount, productQuantity, palletCount, grossWeightKg, netWeightKg, volumeCbm,
volumeWeightKg, items, customerId, customerName, orderType, pallets, destinationType, null);
} }
} }
@@ -86,11 +141,36 @@ public record FirstLegLoadingPlanCompletedV2(
} }
} }
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */ /** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {} public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
Integer manifestVersion, Integer manifestPackageCount,
Integer manifestFragmentCount, String manifestHash) {
public PalletSnapshot {
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
}
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
public record SkuPackageSnapshot(String skuCode, String productName, this(palletNo, skuPackages, null, null, null, null);
}
}
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
int packageCount, int productQuantity, int packageCount, int productQuantity,
List<String> cartonMarks) {} List<String> cartonMarks, java.util.Map<String, Integer> cartonQuantities) {
public SkuPackageSnapshot {
if (sourceInboundDetailId == null || sourceInboundDetailId.isBlank()) {
throw new IllegalArgumentException("sourceInboundDetailId is required for each pallet SKU snapshot");
}
if (cartonMarks == null || cartonQuantities == null || cartonMarks.size() != packageCount
|| new java.util.HashSet<>(cartonMarks).size() != packageCount
|| !cartonQuantities.keySet().equals(new java.util.HashSet<>(cartonMarks))
|| cartonQuantities.values().stream().anyMatch(quantity -> quantity == null || quantity <= 0)
|| cartonQuantities.values().stream().mapToLong(Integer::longValue).sum() != productQuantity) {
throw new IllegalArgumentException("cartonQuantities must exactly cover cartonMarks and productQuantity");
}
cartonMarks = List.copyOf(cartonMarks);
cartonQuantities = java.util.Map.copyOf(cartonQuantities);
}
}
} }
@@ -0,0 +1,45 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** 装柜完成 V3:在 V2 货物快照上增加整柜五类国内装柜照片引用。 */
public record FirstLegLoadingPlanCompletedV3(
String loadingPlanId, String planNo,
@JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<FirstLegLoadingPlanCompletedV2.Order> orders, List<LoadingEvidence> loadingEvidence) {
public FirstLegLoadingPlanCompletedV3 {
if (loadingPlanId == null || loadingPlanId.isBlank()
|| containerBookingId == null || containerBookingId.isBlank()) {
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
}
containerNo = ContainerNumbers.requireCanonical(containerNo);
orders = orders == null ? List.of() : List.copyOf(orders);
loadingEvidence = loadingEvidence == null ? List.of() : List.copyOf(loadingEvidence);
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
var owner = orders.get(0);
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|| !owner.customerName().equals(order.customerName()))) {
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
}
if (loadingEvidence.size() != 5
|| loadingEvidence.stream().map(LoadingEvidence::evidenceType).distinct().count() != 5) {
throw new IllegalArgumentException("five unique loading evidence types are required");
}
}
public record LoadingEvidence(String evidenceType, long fileId, String fileName) {
public LoadingEvidence {
if (evidenceType == null || !evidenceType.matches("PHOTO_STEP_[1-4]|SEAL")) {
throw new IllegalArgumentException("unsupported loading evidence type");
}
if (fileId <= 0) throw new IllegalArgumentException("loading evidence fileId must be positive");
if (fileName == null || fileName.isBlank() || fileName.length() > 255) {
throw new IllegalArgumentException("loading evidence fileName is required");
}
fileName = fileName.trim();
}
}
}

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