Compare commits

...
787 Commits
Author SHA1 Message Date
liuyihe f86fab838a 一件代发拣货 2026-09-01 10:35:54 +08:00
liuyihe 2f1bda8369 一件代发拣货 2026-09-01 09:57:10 +08:00
liuyihe a213be67c5 尾程暂扣回传 2026-08-31 15:22:32 +08:00
liuyihe a4189a0074 提交 2026-08-31 14:39:40 +08:00
liuyihe a4429f95b7 提交 2026-08-30 18:49:15 +08:00
liuyihe 5ddaa4d51f 提交 2026-08-28 15:25:50 +08:00
liuyihe 8a7c92bb0f 提交 2026-08-28 15:16:36 +08:00
liuyihe 4f2b670ebf 暂扣 标记合托功能 2026-08-28 14:55:18 +08:00
liuyihe 66b9c3aa77 暂扣 标记合托功能 2026-08-28 14:20:48 +08:00
liuyihe 6a83008ba6 提交 2026-08-28 09:59:17 +08:00
liuyihe 5f341d6fb5 一件代发 2026-08-27 17:23:18 +08:00
liuyihe 8d39c61863 提交 2026-08-27 16:54:03 +08:00
liuyihe 7e0911951d Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
2026-08-27 13:40:26 +08:00
liuyihe a79999d97c 提交 2026-08-27 13:35:52 +08:00
ouyangxq 9b6e257f8d fix(db): support clean test database initialization 2026-08-26 21:17:14 +08:00
ouyangxq 0103ea96c8 test: lock overseas PDA pallet routing 2026-08-26 20:39:03 +08:00
liuyihe 3975759458 提交 2026-08-26 19:46:14 +08:00
ouyangxq e9b03a3d35 test: allow inbound operation descriptions 2026-08-26 18:07:39 +08:00
ouyangxq 29c27d2fd7 fix: resolve FBA PDA carton by inbound prefix 2026-08-26 18:03:17 +08:00
ouyangxq cbcbb9c764 fix(ci): make AUTO deployment incremental 2026-08-26 18:01:47 +08:00
ouyangxq 64047df5b3 优化 2026-08-26 17:50:56 +08:00
ouyangxq aad8dd6344 优化 2026-08-26 17:31:12 +08:00
ouyangxq 9df87b17ac 优化 2026-08-26 17:31:09 +08:00
ouyangxq 2280154773 优化 2026-08-26 17:29:07 +08:00
ouyangxq 05e3aa4e8d 优化 2026-08-26 17:27:09 +08:00
ouyangxq ef45325643 Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-08-26 17:27:05 +08:00
ouyangxq 92d7c0a7b0 优化 2026-08-26 17:23:38 +08:00
ouyangxq 795e852944 优化 2026-08-26 17:09:02 +08:00
ouyangxq 6c4543c18c 优化 2026-08-26 16:50:22 +08:00
liuyihe 63182a2b88 Merge branch 'dev' into dev-lyhfive 2026-08-26 16:07:27 +08:00
liuyihe b04c58a76a Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
2026-08-26 16:05:24 +08:00
ouyangxq fd1a81594a 优化 2026-08-26 15:58:54 +08:00
liuyihe 5c6c0ef5ca 提交 2026-08-26 15:51:46 +08:00
ouyangxq 57a2f6ef28 fix(firstleg): enforce strict manual node progression 2026-08-26 15:44:05 +08:00
ouyangxq 2ebea9e789 fix(firstleg): allow audited carrier node time corrections 2026-08-26 15:41:03 +08:00
wujinliang f2877df697 1 2026-08-26 15:32:10 +08:00
ouyangxq 4ae236fe11 优化 2026-08-26 15:24:48 +08:00
liuyihe 93e1540e3f 提交 2026-08-26 15:02:14 +08:00
ouyangxq 3f9ee28d22 优化 2026-08-26 14:56:33 +08:00
ouyangxq 36534fe9ce fix(warehouse): align partner import carrier columns 2026-08-26 14:55:43 +08:00
ouyangxq 1f98c5ab9c 优化 2026-08-26 14:49:11 +08:00
ouyangxq bf960f10d1 优化 2026-08-26 14:48:43 +08:00
liuyihe e31fd8caa2 提交 2026-08-26 14:36:04 +08:00
ouyangxq 0f4524c795 优化 2026-08-26 14:30:55 +08:00
ouyangxq 14126dce5b 优化 2026-08-26 14:30:35 +08:00
ouyangxq dc564c9530 优化 2026-08-26 14:12:05 +08:00
ouyangxq 7f05183904 优化 2026-08-26 14:08:41 +08:00
ouyangxq 4398e8b347 优化 2026-08-26 13:58:33 +08:00
liuyihe 394c690601 提交 2026-08-26 13:51:19 +08:00
ouyangxq fc0a6ea7ca fix(firstleg): expose container documents and aggregate departure gates 2026-08-26 13:07:30 +08:00
ouyangxq 1584c7a0a3 fix(firstleg): make truck status informational for departure 2026-08-26 12:36:14 +08:00
ouyangxq 8eeb52ba77 feat(firstleg): confirm domestic departure by container 2026-08-26 12:05:42 +08:00
ouyangxq 9ea2aa46ec Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-26 11:11:03 +08:00
ouyangxq cf5d603a21 优化 2026-08-26 11:10:42 +08:00
ouyangxq 15421db421 fix(event-bridge): restore overseas event delivery 2026-08-26 11:06:24 +08:00
liuyihe 270372f56a 提交 2026-08-26 11:05:26 +08:00
wujinliang 897ddd476f 1 2026-08-25 22:08:44 +08:00
ouyangxq 44fee01761 test: align active booking allocation contract 2026-08-25 20:21:42 +08:00
ouyangxq 5d2b6c22e6 test: freeze server-filtered booking candidates 2026-08-25 20:17:12 +08:00
ouyangxq ead9565a37 fix: align ocean booking link eligibility 2026-08-25 20:13:48 +08:00
liuyihe 3bf68c1083 提交 2026-08-25 19:58:08 +08:00
ouyangxq 6e55d94f33 fix: add ocean booking link candidates query 2026-08-25 19:44:10 +08:00
ouyangxq 7d36759f3f 优化 2026-08-25 19:07:34 +08:00
ouyangxq 65c01f8b3d test: isolate phase4 carrier fixtures 2026-08-25 18:32:12 +08:00
ouyangxq b6226f1ca6 fix: replay completed pallet operations 2026-08-25 18:21:39 +08:00
ouyangxq ca69434698 fix: persist PDA movement state semantics 2026-08-25 18:11:29 +08:00
ouyangxq 335ad8269f fix: execute overseas pallet transitions 2026-08-25 18:07:48 +08:00
ouyangxq 7471f7e81f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-25 17:55:27 +08:00
ouyangxq 62407610cb 优化 2026-08-25 17:54:50 +08:00
wujinliang 0def6114ee 1 2026-08-25 17:42:38 +08:00
ouyangxq 25cb3bfa54 test: await PDA projection and pending receipts 2026-08-25 17:25:38 +08:00
ouyangxq 4e8c15dbfa test: wait for customer dimension initialization 2026-08-25 17:17:32 +08:00
ouyangxq 3ab1a9d173 fix(lastleg): remove duplicate vehicle repository mapper 2026-08-25 17:01:52 +08:00
ouyangxq 505d448850 优化 2026-08-25 16:41:02 +08:00
ouyangxq 2dcfdd5fc8 优化 2026-08-25 16:40:54 +08:00
ouyangxq eb1a8895ac 优化 2026-08-25 16:40:50 +08:00
ouyangxq 87c5e18ca6 优化 2026-08-25 16:40:48 +08:00
ouyangxq 1f6f1a0677 test: complete phase4 PDA shipping prerequisites 2026-08-25 15:27:26 +08:00
ouyangxq fa92936406 fix(deploy): bind development gateways to loopback 2026-08-25 15:16:41 +08:00
ouyangxq 44198866d9 优化 2026-08-25 15:08:34 +08:00
ouyangxq e815abcbd7 fix(firstleg): retain dropship carton label pool 2026-08-25 15:07:06 +08:00
liuyihe 749a0d40a7 Merge branch 'dev' into dev-lyhfive
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
2026-08-25 15:06:11 +08:00
ouyangxq 39988cbaa5 fix(firstleg): publish only actually loaded items 2026-08-25 15:00:03 +08:00
ouyangxq a067a9f056 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java
2026-08-25 14:53:27 +08:00
ouyangxq f325d57fc1 优化 2026-08-25 14:46:06 +08:00
ouyangxq 5abc7543ae fix(firstleg): align loading completion trigger 2026-08-25 14:41:48 +08:00
ouyangxq 121e8ea619 fix(firstleg): validate settled partial pick totals 2026-08-25 14:33:38 +08:00
ouyangxq 7eca39e4fd fix(firstleg): honor terminal partial pick outcomes 2026-08-25 14:24:59 +08:00
ouyangxq f1af749616 test: target dynamic loading detail precisely 2026-08-25 14:18:13 +08:00
ouyangxq 5fb80d7624 fix(firstleg): reuse running preload lifecycle 2026-08-25 14:07:52 +08:00
ouyangxq 9093550615 test: verify complete PDA batch history 2026-08-25 13:59:58 +08:00
ouyangxq 98766e2b1a fix(firstleg): continue same-plan loading allocations 2026-08-25 13:59:03 +08:00
ouyangxq 72f3f9f971 fix(firstleg): execute carrier schedule due queries 2026-08-25 13:46:33 +08:00
ouyangxq 0302985f21 fix(firstleg): settle zero-package measurements safely 2026-08-25 13:43:22 +08:00
ouyangxq 6d8e4c85c5 test(lastleg): align rest client gateway setup 2026-08-25 13:26:05 +08:00
ouyangxq 2cfaf8e51b fix(lastleg): isolate shipping node version stream 2026-08-25 13:23:05 +08:00
ouyangxq 79eb3efe61 fix(lastleg): align overseas business incremental schema 2026-08-25 13:07:01 +08:00
ouyangxq 995c1833f8 test: align phase3 and overseas business acceptance 2026-08-25 13:06:09 +08:00
ouyangxq d686b05966 fix(firstleg): expose event bridge relay failures 2026-08-25 13:02:26 +08:00
ouyangxq 4cca194eee 优化 2026-08-25 12:30:08 +08:00
ouyangxq 1382902f86 优化 2026-08-25 12:30:07 +08:00
ouyangxq 12dad6673d fix(lastleg): register PDA operation mappers 2026-08-25 12:29:47 +08:00
ouyangxq 4b5e5a1e4e 优化 2026-08-25 12:27:47 +08:00
ouyangxq 0e0b6f8264 test: add architecture and OpenAPI validation tooling 2026-08-25 12:22:14 +08:00
ouyangxq 24df851acf fix(lastleg): register shipping mapper on startup 2026-08-25 12:18:19 +08:00
ouyangxq d9c8cc478e 优化 2026-08-25 11:54:19 +08:00
ouyangxq 322de03312 fix(ci): serialize deployments per service 2026-08-25 11:53:59 +08:00
ouyangxq 4fbf8bfb53 优化 2026-08-25 11:52:25 +08:00
ouyangxq d64706e97b fix: keep development gateways reachable 2026-08-25 11:44:38 +08:00
ouyangxq d066381ae2 fix: expose failed deployment container logs 2026-08-25 11:41:25 +08:00
ouyangxq 6ba4537d3d fix(deploy): bind development ports to loopback 2026-08-25 11:39:12 +08:00
ouyangxq 68bdc3e6bb test: cover overseas pda warehouse operation lifecycles 2026-08-25 11:25:13 +08:00
ouyangxq 042d0b738a feat: complete overseas pda warehouse operations 2026-08-25 11:20:36 +08:00
ouyangxq 45a3b9adfd test: verify pda warehouse queries after receiving 2026-08-25 10:50:58 +08:00
ouyangxq 2a16c91c89 test: sequence pickup readiness before overseas receiving 2026-08-25 10:48:10 +08:00
ouyangxq a6be840588 优化 2026-08-25 10:39:43 +08:00
ouyangxq 52b9fc382f feat: gate overseas receiving and harden phase4 workflows 2026-08-25 10:31:04 +08:00
ouyangxq cb78bc6b41 test: cover partial and concurrent pda completion 2026-08-25 09:58:59 +08:00
ouyangxq b67217332a 优化 2026-08-25 09:52:49 +08:00
ouyangxq b211121365 test: expose acceptance coverage metrics 2026-08-25 09:50:02 +08:00
ouyangxq 46dd0b54eb test: isolate pda receipt idempotency keys 2026-08-25 09:27:45 +08:00
ouyangxq df4b7a1f6e test: carry frozen sku receipts into pda acceptance 2026-08-25 09:23:28 +08:00
ouyangxq 29b2f080d8 test: preserve dropship producer fixture type 2026-08-25 09:16:00 +08:00
ouyangxq 4839243f21 feat: add dropship fulfillment producer chain 2026-08-25 08:40:27 +08:00
ouyangxq c218bfad6b fix: expose warehouse transfer application status 2026-08-25 08:21:47 +08:00
ouyangxq 85a288e4f2 fix: preserve overseas pallet identity 2026-08-25 08:12:58 +08:00
ouyangxq 73d206e3f3 test: complete phase3 phase4 acceptance coverage 2026-08-25 00:24:43 +08:00
ouyangxq 84ad5e779a test: make price-book acceptance portable 2026-08-25 00:08:59 +08:00
ouyangxq 24149c062a fix: migrate event bridge customer columns 2026-08-25 00:02:45 +08:00
ouyangxq e94f8629d2 fix: retain event bridge rejection evidence 2026-08-24 23:56:52 +08:00
ouyangxq 1c40548986 fix: isolate domestic bridge target variable 2026-08-24 23:52:38 +08:00
ouyangxq 55a9d1af09 fix: prioritize domestic bridge endpoint 2026-08-24 23:49:07 +08:00
ouyangxq a22a284e7d fix: route unload events to domestic bridge 2026-08-24 23:43:08 +08:00
ouyangxq 058bf9f4e6 test: complete phase4 cross-region handoff 2026-08-24 23:35:26 +08:00
ouyangxq 615ecb4ba1 test: complete overseas carton and sailing fixtures 2026-08-24 23:28:24 +08:00
ouyangxq 985d952e54 test: provision canonical carrier fixture 2026-08-24 23:19:27 +08:00
ouyangxq bf4fd95841 fix: align phase4 receiving and carrier fixtures 2026-08-24 23:12:04 +08:00
ouyangxq 1e8d85c714 fix: support id-only overseas destinations 2026-08-24 22:51:33 +08:00
ouyangxq 0e73a79479 fix: serialize phase4 inventory and outbound gates 2026-08-24 22:32:37 +08:00
ouyangxq 3fbb7653f7 fix: complete phase4 receiving acceptance chain 2026-08-24 22:14:41 +08:00
ouyangxq 8cc4c6f6da fix: bind overseas receiving to shared warehouse 2026-08-24 21:46:19 +08:00
ouyangxq 3607bebbfd fix: close phase4 overseas acceptance gaps 2026-08-24 21:00:22 +08:00
ouyangxq 015fc09091 补充PDA装柜完成阶段诊断 2026-08-24 20:47:13 +08:00
ouyangxq 005c3f54e2 test: align full acceptance contracts 2026-08-24 20:14:40 +08:00
ouyangxq 5edc2fc1bd fix(lastleg): restore overseas receiving build 2026-08-24 20:14:39 +08:00
ouyangxq c93fdc44d5 1 2026-08-24 19:58:35 +08:00
ouyangxq 9e0f41e78b test: strengthen phase4 overseas business acceptance 2026-08-24 19:45:22 +08:00
ouyangxq a811c2ca06 test: harden carrier node HTTP acceptance 2026-08-24 19:44:28 +08:00
ouyangxq ac806704be chore(test): keep master-data changes out of event gate 2026-08-24 19:42:20 +08:00
ouyangxq bffe7d3b23 test: strengthen overseas warehouse PDA contracts 2026-08-24 19:41:16 +08:00
ouyangxq 1e4b216afa test(events): add cross-region HTTP acceptance gates 2026-08-24 19:40:46 +08:00
ouyangxq 2f986cf5e3 优化 2026-08-24 19:35:01 +08:00
ouyangxq 89bd746dbd 优化 2026-08-24 19:29:39 +08:00
ouyangxq 5c54785d77 优化 2026-08-24 19:28:32 +08:00
ouyangxq aa3e72af28 Merge origin/test into dev 2026-08-24 19:27:54 +08:00
ouyangxq 551560efda 优化 2026-08-24 19:23:20 +08:00
ouyangxq 7e080622f3 fix(ci): isolate event bridge region parameter 2026-08-24 19:15:15 +08:00
ouyangxq 360115de35 修复确认预装后重新分配失败 2026-08-24 18:18:08 +08:00
ouyangxq 111500d010 test(lastleg): align receiving completion fixtures 2026-08-24 18:17:44 +08:00
ouyangxq 4f2abf2894 test(lastleg): type receiving mapper inserts 2026-08-24 18:10:31 +08:00
liuyihe 5c331f173d 提交 2026-08-24 17:58:47 +08:00
ouyangxq 52e4b914b1 fix(lastleg): complete receiving by container 2026-08-24 17:56:43 +08:00
ouyangxq f4a1ec92de 优化 2026-08-24 17:51:11 +08:00
ouyangxq 4893542ec4 1 2026-08-24 17:48:08 +08:00
ouyangxq 633cec7c68 fix(lastleg): disambiguate consumer test fixtures 2026-08-24 17:43:03 +08:00
ouyangxq a320198b47 fix(ci): keep Jenkins boolean continuations parseable 2026-08-24 17:37:36 +08:00
ouyangxq bb74a05562 feat(lastleg): harden container receiving contract 2026-08-24 17:37:15 +08:00
ouyangxq be1eb4f45c fix(ci): carry dual bridge intent through stable parameters 2026-08-24 17:34:11 +08:00
ouyangxq 9d09b77442 fix(ci): bridge BOTH through legacy downstream choices 2026-08-24 17:23:05 +08:00
ouyangxq ce82916036 fix(lastleg): align inbound snapshot with schema 2026-08-24 17:22:18 +08:00
ouyangxq 5a4bcf130a feat(ci): deploy both event bridge regions by default 2026-08-24 17:19:36 +08:00
ouyangxq 3d8a1fac20 优化 2026-08-24 17:15:48 +08:00
liuyihe 01675b8220 提交 2026-08-24 17:12:30 +08:00
ouyangxq 132d96a99b 修复海外报价合作仓候选缓存刷新 2026-08-24 17:10:39 +08:00
ouyangxq 8708904dab 修复装柜清单托盘数快照 2026-08-24 17:03:29 +08:00
ouyangxq 3019f5da77 fix(event-bridge): retain downstream business errors 2026-08-24 16:59:02 +08:00
ouyangxq 8e12dd9a38 fix(test): select container-backed PDA fixtures 2026-08-24 16:40:25 +08:00
ouyangxq 6aca41427c feat(phase4): automate overseas PDA inbound lifecycle 2026-08-24 15:38:28 +08:00
ouyangxq ef9f253f47 feat(lastleg): expose inbound PDA work instructions 2026-08-24 15:25:34 +08:00
ouyangxq 95b72afdcc fix(phase4): enforce customer and pallet versions 2026-08-24 15:14:18 +08:00
ouyangxq e773816c98 fix(db): upgrade overseas pallet FBA field 2026-08-24 15:10:10 +08:00
ouyangxq e11a528dfe test(phase4): provision overseas lifecycle fixtures 2026-08-24 15:07:17 +08:00
liuyihe ce9f08c756 提交 2026-08-24 14:52:50 +08:00
ouyangxq cd97229791 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-24 14:47:54 +08:00
ouyangxq c88d99a593 修复订单列表托数汇总 2026-08-24 14:42:38 +08:00
ouyangxq a8326455fc fix(firstleg): allocate handover carton marks per inbound 2026-08-24 14:40:51 +08:00
ouyangxq 2f32f1d827 test(phase3): finalize loading before split acceptance 2026-08-24 14:34:01 +08:00
ouyangxq e781cbe9cb fix(firstleg): normalize split carton label pools 2026-08-24 14:20:59 +08:00
ouyangxq 8e61cfd202 fix(firstleg): transfer carton labels to remainder inbound 2026-08-24 14:08:17 +08:00
ouyangxq 506cc69808 test(phase3): provision dispatch and handoff evidence 2026-08-24 14:01:02 +08:00
ouyangxq f041cf524b test(phase3): complete remaining hard-gate fixtures 2026-08-24 13:57:02 +08:00
ouyangxq 23335ee8d0 test(phase3): satisfy finalization fixture gates 2026-08-24 13:53:10 +08:00
ouyangxq 98ec62f66e test(phase3): align full acceptance fixtures 2026-08-24 13:48:16 +08:00
ouyangxq b012d64b54 perf(ci): run selected tests once before service builds 2026-08-24 13:43:38 +08:00
ouyangxq 9dd2fcb7e5 test(phase3): validate multi-owner loaded slices 2026-08-24 13:41:59 +08:00
liuyihe 6a86dbcb37 Merge remote-tracking branch 'origin/dev-lyhfive' into dev-lyhfive 2026-08-24 13:41:54 +08:00
ouyangxq 6ddf0d823b fix(firstleg): share container evidence across loading owners 2026-08-24 13:37:47 +08:00
ouyangxqandliuyihe b72dd54b80 fix container customer snapshot schema migration 2026-08-24 13:36:38 +08:00
ouyangxq 92dbeb1676 fix(firstleg): finalize loading before customs acceptance 2026-08-24 12:52:23 +08:00
ouyangxq 4377a7acd9 fix(firstleg): allow confirmed preload reassign during loading 2026-08-24 12:34:34 +08:00
ouyangxq 2389a63858 fix IAM security event correlation id 2026-08-24 12:30:18 +08:00
ouyangxq ae3026dfdb Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-24 12:01:44 +08:00
ouyangxq f1639b9fe3 优化 2026-08-24 12:01:37 +08:00
ouyangxq b3695276b3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java
2026-08-24 11:58:22 +08:00
ouyangxq 739c20cb3d fix lastleg order detail customer schema 2026-08-24 11:55:50 +08:00
ouyangxq d59fccc731 优化 2026-08-24 11:52:32 +08:00
ouyangxq 4977b09af9 fix phase3 loading acceptance sequence 2026-08-24 11:49:31 +08:00
ouyangxq 257c019b36 fix phase3 acceptance roots and payable validation 2026-08-24 11:48:16 +08:00
ouyangxq 044678f65c fix carrier history action schema upgrade 2026-08-24 11:36:25 +08:00
ouyangxq 287591c75f fix container customer snapshot schema migration 2026-08-24 11:19:33 +08:00
ouyangxq fe4eb0b593 perf(ci): allow two concurrent non-production deploys 2026-08-24 11:17:17 +08:00
ouyangxq 9198ce02d9 fix(ci): isolate Docker registry credentials 2026-08-24 11:00:03 +08:00
ouyangxq a4a693837e perf(ci): acquire deploy lock before target agent 2026-08-24 10:56:33 +08:00
ouyangxq c8574326dd fix smart loading candidate result mapping 2026-08-24 10:51:49 +08:00
ouyangxq 938c5771b3 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	Jenkinsfile
#	Jenkinsfile.service
#	deploy/scripts/deploy-built-service.sh
#	scripts/test-environment-pipeline-contract.test.mjs
2026-08-24 10:49:08 +08:00
ouyangxq 0fbba13714 优化 2026-08-24 10:35:40 +08:00
ouyangxq ccfd024aef fix phase3 phase4 development acceptance blockers 2026-08-24 10:33:05 +08:00
ouyangxq 81899015cf 优化 2026-08-24 09:29:26 +08:00
ouyangxq ae9238b3cf feat(ci): rollback unhealthy deployments 2026-08-22 18:26:47 +08:00
ouyangxq 657f3553d8 fix(ci): retain service source workspaces 2026-08-22 18:01:20 +08:00
ouyangxq abab08e879 perf(ci): reuse Git workspaces across builds 2026-08-22 18:01:15 +08:00
ouyangxq 52b673b878 perf(ci): release coordinator builder executor 2026-08-22 18:01:10 +08:00
ouyangxq d3e16bf29f fix(ci): deploy on target environment agents 2026-08-22 18:01:10 +08:00
ouyangxq 1df87e5d17 fix(ci): retain service source workspaces 2026-08-22 17:55:50 +08:00
ouyangxq 46e5c14b86 perf(ci): reuse Git workspaces across builds 2026-08-22 17:53:18 +08:00
ouyangxq 309900de82 perf(ci): release coordinator builder executor 2026-08-22 17:46:11 +08:00
ouyangxq 232c2657f1 fix(ci): deploy on target environment agents 2026-08-22 17:43:20 +08:00
liuyihe e398f1f7e0 修改提交 2026-08-22 12:35:32 +08:00
ouyangxq e92cfe18ad ci: use internal Java base image 2026-08-22 12:22:04 +08:00
ouyangxq 996102e77d ci: use internal Java base image 2026-08-22 12:20:26 +08:00
ouyangxq 668668b505 fix(ci): schedule coordinator on builder agents 2026-08-22 12:07:35 +08:00
ouyangxq fd1c582d45 ci: serialize registry-based environment deployments 2026-08-22 12:07:35 +08:00
ouyangxq 0f296e016e ci: use dedicated registry credentials 2026-08-22 12:07:35 +08:00
ouyangxq a7ec6ed285 ci: publish development and test images to registry 2026-08-22 12:07:35 +08:00
ouyangxq 0d3a27f4ba build(docker): layer Spring Boot service images 2026-08-22 12:07:35 +08:00
ouyangxq dc131f26af Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 11:47:12 +08:00
ouyangxq 775b2c4863 优化 2026-08-22 11:46:41 +08:00
ouyangxq cee3ee07a8 fix(ci): schedule coordinator on builder agents 2026-08-22 11:45:14 +08:00
ouyangxq 5a78e9ca54 ci: default development coordinator to dev jobs 2026-08-22 11:14:28 +08:00
ouyangxq aa80ff4b2e fix(ci): use compatible Jenkins job copy API 2026-08-22 11:13:09 +08:00
ouyangxq 7aebc3c0cb ci: add safe dev job provisioning script 2026-08-22 11:08:07 +08:00
ouyangxq ba8a6b7832 ci: serialize registry-based environment deployments 2026-08-22 11:01:06 +08:00
ouyangxq ff9c1e2bc1 ci: use dedicated registry credentials 2026-08-22 10:50:33 +08:00
ouyangxq 3b3d672fb1 ci: publish development and test images to registry 2026-08-22 10:38:40 +08:00
ouyangxq b25087e60b build(docker): layer Spring Boot service images 2026-08-22 10:27:28 +08:00
ouyangxq 85173b608d Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 10:20:39 +08:00
ouyangxq 1f2c4cd8bb 优化 2026-08-22 10:20:38 +08:00
liuyihe 93feb7da67 修改提交 2026-08-22 10:16:45 +08:00
ouyangxq 76c3662dda 优化 2026-08-22 10:02:10 +08:00
ouyangxq af8724dd70 优化 2026-08-22 09:56:26 +08:00
liuyihe ab2f3ed72e Merge remote-tracking branch 'origin/dev' into dev 2026-08-22 09:52:04 +08:00
liuyihe 15636a1600 修改提交 2026-08-22 09:51:41 +08:00
ouyangxq ff926d5b74 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-22 09:51:03 +08:00
ouyangxq 988d1bee7c 1 2026-08-22 09:51:01 +08:00
liuyihe 76b41c0812 Merge remote-tracking branch 'origin/dev' into dev 2026-08-21 18:12:25 +08:00
liuyihe 4f14367eba 修改提交 2026-08-21 18:12:07 +08:00
ouyangxq 1a67b3404d Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 17:59:21 +08:00
ouyangxq 72b050af6d Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 17:58:55 +08:00
ouyangxq 24e4a402a9 优化 2026-08-21 17:58:52 +08:00
liuyihe d1d269e96f 修改提交 2026-08-21 17:38:14 +08:00
liuyihe bbd303cd65 修改提交 2026-08-21 17:21:26 +08:00
ouyangxq af97217184 优化 2026-08-21 17:19:49 +08:00
ouyangxq 598749d8e4 优化 2026-08-21 17:07:34 +08:00
ouyangxq 8c280e19c4 优化 2026-08-21 17:05:52 +08:00
ouyangxq cd477fe40a 优化 2026-08-21 16:15:02 +08:00
ouyangxq 8e07342fa1 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-21 16:04:19 +08:00
ouyangxq 0e7df9052a 优化 2026-08-21 16:04:16 +08:00
liuyihe e534784052 提交 2026-08-21 14:42:40 +08:00
liuyihe 5fd00d3151 提交 2026-08-21 11:43:03 +08:00
ouyangxq dceec6cbed 优化 2026-08-21 10:56:51 +08:00
liuyihe e17f03e596 提交 2026-08-21 10:43:25 +08:00
ouyangxq d5e9ba6d4c 优化 2026-08-21 10:38:28 +08:00
ouyangxq 361e6be3be 优化 2026-08-21 09:31:29 +08:00
ouyangxq 96feb8faff 优化 2026-08-20 20:54:30 +08:00
ouyangxq acc5bec3b1 优化 2026-08-20 20:38:46 +08:00
ouyangxq 5400a8e8e7 优化 2026-08-20 20:29:51 +08:00
ouyangxq 11cfba880d 优化 2026-08-20 20:14:21 +08:00
ouyangxq 893d1801b5 优化 2026-08-20 19:55:21 +08:00
ouyangxq d170505327 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 19:11:43 +08:00
ouyangxq 34fb91baf6 优化 2026-08-20 19:10:16 +08:00
ouyangxq 8095a99afa Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	scripts/admin-test-env-modules/orders.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
2026-08-20 18:24:23 +08:00
ouyangxq b0dcbf77e5 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 18:22:20 +08:00
ouyangxq 4256e74c11 优化 2026-08-20 18:21:04 +08:00
ouyangxq 12604afa36 优化 2026-08-20 18:17:32 +08:00
ouyangxq 382ffc7f55 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts:
#	config/nacos/cn/event-bridge-service.yml
#	config/nacos/overseas/lastleg-service.yml
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	event-bridge-service/src/main/resources/application.yml
#	iam/src/main/resources/db/lastleg-permissions.sql
#	lastleg-service/AGENTS.md
#	lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java
#	lastleg-service/src/main/resources/application.yml
#	lastleg-service/src/main/resources/db/lastleg.sql
#	scripts/db/incremental.sql
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java
#	warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java
2026-08-20 18:14:55 +08:00
liuyihe a38396ce3f 提交 2026-08-20 18:06:07 +08:00
ouyangxq d202ca85ef 优化 2026-08-20 18:05:24 +08:00
ouyangxq 1cd8b6a473 优化 2026-08-20 18:02:38 +08:00
ouyangxq b1655a1841 优化 2026-08-20 18:02:20 +08:00
ouyangxq 1e8577dc4e 低碳经济 2026-08-20 17:07:51 +08:00
ouyangxq 5b9ae25f29 优化 2026-08-20 17:00:05 +08:00
liuyihe c9a05b108b 桥接事件 2026-08-20 16:45:47 +08:00
ouyangxq 09f6e28c1f 优化 2026-08-20 16:13:51 +08:00
liuyihe 1f0c0f7751 提交 2026-08-20 15:59:45 +08:00
liuyihe 2847ad1b2d 提交 2026-08-20 15:58:25 +08:00
liuyihe cd34cd426e 提交 2026-08-20 15:25:40 +08:00
liuyihe b55b049e77 优化 2026-08-20 15:20:05 +08:00
ouyangxq 6c7a670f1e 优化 2026-08-20 14:43:07 +08:00
ouyangxq 50d9803566 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 13:49:08 +08:00
ouyangxq 164d862588 优化 2026-08-20 13:42:55 +08:00
liuyihe 802893b2ff Merge remote-tracking branch 'origin/dev' into dev 2026-08-20 13:34:55 +08:00
liuyihe b139255d3b 入库修改 2026-08-20 13:34:34 +08:00
ouyangxq d3183d8f2c 优化 2026-08-20 13:34:29 +08:00
ouyangxq 3e517ee74e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 13:34:04 +08:00
ouyangxq d340c418e8 优化 2026-08-20 13:07:40 +08:00
ouyangxq 3b19a1bc16 Merge branch 'test' into dev 2026-08-20 13:00:49 +08:00
ouyangxq 15d3ffbc0a 优化 2026-08-20 13:00:39 +08:00
ouyangxq b060be18a9 Merge branch 'test' into dev 2026-08-20 12:50:05 +08:00
ouyangxq b029f4ce28 优化 2026-08-20 12:49:54 +08:00
ouyangxq fd1da73fa8 Merge branch 'test' into dev 2026-08-20 12:34:49 +08:00
ouyangxq c83620a86a 优化 2026-08-20 12:34:10 +08:00
ouyangxq b464de1edb Merge branch 'test' into dev 2026-08-20 10:39:13 +08:00
ouyangxq 3d57abf260 优化 2026-08-20 10:37:32 +08:00
ouyangxq 0384f54da7 Merge branch 'test' into dev 2026-08-20 10:28:57 +08:00
ouyangxq bdf545ee0a 优化 2026-08-20 10:28:45 +08:00
ouyangxq 0575ea247f Merge branch 'test' into dev 2026-08-20 10:23:00 +08:00
ouyangxq 7a5719b5a1 优化 2026-08-20 10:22:51 +08:00
ouyangxq d36e480413 优化 2026-08-20 10:17:39 +08:00
ouyangxq e469749a98 Merge branch 'test' into dev 2026-08-20 09:50:48 +08:00
ouyangxq be5d89a4e6 优化 2026-08-20 09:49:28 +08:00
ouyangxq 7cf4a8d562 优化 2026-08-20 09:43:31 +08:00
ouyangxq cf2356bccb Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 09:10:32 +08:00
ouyangxq f227cf05a3 优化 2026-08-20 09:10:30 +08:00
liuyihe f03ea227ed 暂存提交 2026-08-19 17:23:54 +08:00
liuyihe 26eba227eb 暂存提交 2026-08-19 17:22:03 +08:00
ouyangxq 064247a8c9 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 17:08:42 +08:00
ouyangxq f78b44297d 优化 2026-08-19 17:08:41 +08:00
liuyihe 88100f3536 Merge remote-tracking branch 'origin/dev' into dev 2026-08-19 16:49:58 +08:00
liuyihe 63618c20dc 暂存提交 2026-08-19 16:49:41 +08:00
ouyangxq a4ec4e8e99 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 16:45:45 +08:00
ouyangxq 3b8934fc60 优化 2026-08-19 16:45:44 +08:00
liuyihe 6acb6178da 暂存提交 2026-08-19 16:24:02 +08:00
ouyangxq f5c4bf130a Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:14:04 +08:00
ouyangxq f25e270f22 优化 2026-08-19 14:13:47 +08:00
ouyangxq 000fd10b6a fix(sql): complete IAM security event comments 2026-08-19 14:04:16 +08:00
ouyangxq 23e78bb33f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:02:11 +08:00
ouyangxq 41d70841fc 优化 2026-08-19 09:58:43 +08:00
ouyangxq 008d7ba574 优化 2026-08-19 09:57:50 +08:00
ouyangxq b908d9f248 优化 2026-08-19 09:56:17 +08:00
ouyangxq 80527fa15f 优化 2026-08-19 09:55:29 +08:00
ouyangxq a381d443da 优化 2026-08-19 09:44:18 +08:00
ouyangxq b5864bfcda 优化 2026-08-19 09:42:07 +08:00
liuyihe 6b077c20db 暂存提交 2026-08-19 09:28:40 +08:00
ouyangxq ff1d330c2d 优化 2026-08-19 09:14:30 +08:00
ouyangxq 8845a66749 优化 2026-08-18 19:04:22 +08:00
ouyangxq 688e5649b0 优化 2026-08-18 17:40:32 +08:00
ouyangxq d70c08a378 优化 2026-08-18 16:42:01 +08:00
ouyangxq a9061eca54 优化 2026-08-18 16:39:39 +08:00
ouyangxq 7fe5b4e31f 优化 2026-08-18 16:10:44 +08:00
ouyangxq fa77170304 优化 2026-08-18 16:09:52 +08:00
ouyangxq 82ee80226e 优化 2026-08-18 16:03:42 +08:00
ouyangxq 12e9f3746e 优化 2026-08-18 15:49:52 +08:00
ouyangxq 6d5ec5759b 优化 2026-08-18 15:49:37 +08:00
ouyangxq 9db371ab0e 伦 2026-08-18 15:47:44 +08:00
ouyangxq 81e6610eb8 傣给 2026-08-18 15:46:56 +08:00
ouyangxq b26e64bb8d 优化 2026-08-18 15:46:52 +08:00
ouyangxq 4b0b3e9172 优化 2026-08-18 15:43:58 +08:00
ouyangxq 4e4d047862 优化 2026-08-18 11:46:10 +08:00
ouyangxq cad9a81d21 优化 2026-08-18 11:44:39 +08:00
ouyangxq ec4b1e0fe5 优化 2026-08-18 11:41:10 +08:00
ouyangxq 3e1010fa26 优化 2026-08-18 11:32:55 +08:00
ouyangxq ea26eecc50 优化 2026-08-18 09:09:28 +08:00
ouyangxq 092c003e3a 优化 2026-08-17 20:21:44 +08:00
ouyangxq b8c7f9fea2 优化 2026-08-17 20:01:48 +08:00
ouyangxq 45b51415cf 优化 2026-08-17 19:51:54 +08:00
ouyangxq cddd23e4c7 优化 2026-08-17 19:13:08 +08:00
ouyangxq 5199146e97 优化 2026-08-17 18:32:38 +08:00
ouyangxq 5c22018edd 优化 2026-08-17 18:04:04 +08:00
ouyangxq fd4afd6ae6 优化 2026-08-17 18:00:38 +08:00
ouyangxq 7c66097672 优化 2026-08-17 17:49:03 +08:00
ouyangxq c71db4b481 优化 2026-08-17 16:45:43 +08:00
ouyangxq 592da4d7e2 优化 2026-08-17 16:17:20 +08:00
ouyangxq 75580c3585 优化 2026-08-17 16:07:20 +08:00
ouyangxq 68d9216cb8 优化 2026-08-17 15:55:11 +08:00
ouyangxq 5f79616623 优化 2026-08-17 15:41:42 +08:00
ouyangxq 4abcb2b80f 优化 2026-08-17 14:50:00 +08:00
ouyangxq a34b5d5ba0 优化 2026-08-17 14:46:44 +08:00
ouyangxq b914bb7532 优化 2026-08-17 13:47:20 +08:00
ouyangxq 6739c83467 优化 2026-08-17 13:24:48 +08:00
ouyangxq ac6cf9c904 优化 2026-08-17 12:31:24 +08:00
ouyangxq 8566c6ea9e 优化 2026-08-17 11:23:35 +08:00
ouyangxq 6b416621bb 优化 2026-08-17 11:23:28 +08:00
ouyangxq d51e054def 优化 2026-08-17 10:54:08 +08:00
ouyangxq 7a9c72a6a2 优化 2026-08-17 09:41:08 +08:00
ouyangxq 20d836ab3d test: preserve fee item identifiers 2026-08-15 18:07:23 +08:00
ouyangxq f2a49d032a test: map finance supplier category from fee type 2026-08-15 18:03:47 +08:00
ouyangxq 2cec065582 test: provision finance fixture prerequisites 2026-08-15 17:58:15 +08:00
ouyangxq 0e2a291e7a test: use active suppliers for finance fixtures 2026-08-15 17:52:46 +08:00
ouyangxq 5e1e49085d test: normalize finance container allocation sources 2026-08-15 17:45:51 +08:00
ouyangxq 28daf15082 test: confirm plans before PDA acceptance 2026-08-15 17:40:01 +08:00
ouyangxq 05921eca13 test: stabilize preload push replay assertions 2026-08-15 17:32:16 +08:00
ouyangxq 8350bcdf00 fix: replay preload confirmation idempotently 2026-08-15 17:27:26 +08:00
ouyangxq a35794e918 feat: close preload confirmation and loading flow 2026-08-15 17:04:00 +08:00
ouyangxq 5b8e7d6989 fix: harden smart loading push idempotency 2026-08-15 12:39:30 +08:00
ouyangxq 0ddffeddc8 优化 2026-08-15 12:33:23 +08:00
ouyangxq 3a9558bd1c 优化 2026-08-15 12:12:34 +08:00
ouyangxq 08187363a1 优化 2026-08-15 11:51:15 +08:00
ouyangxq 46cb95db15 优化 2026-08-15 11:36:53 +08:00
ouyangxq 282eb73371 优化 2026-08-15 11:20:45 +08:00
ouyangxq 0f915d7b24 优化 2026-08-15 11:19:10 +08:00
ouyangxq c544b1bc39 优化 2026-08-15 11:19:08 +08:00
ouyangxq 792965c822 优化 2026-08-15 11:14:57 +08:00
ouyangxq dfa3e8cc53 优化 2026-08-15 11:14:50 +08:00
ouyangxq fdea2a6f31 优化 2026-08-15 11:14:47 +08:00
ouyangxq c4adba57db 优化 2026-08-15 11:12:19 +08:00
ouyangxq 557ab75319 优化 2026-08-15 11:05:58 +08:00
ouyangxq c4282020b5 优化 2026-08-15 10:35:54 +08:00
ouyangxq 555acca779 优化 2026-08-15 10:32:39 +08:00
ouyangxq 9e7cc55eab 优化 2026-08-15 10:27:15 +08:00
ouyangxq c6d7fb0d84 优化 2026-08-15 10:09:04 +08:00
ouyangxq 233f8f312d 优化 2026-08-15 10:05:03 +08:00
ouyangxq bafabf7217 优化 2026-08-15 09:57:46 +08:00
ouyangxq e7c0788a0b 优化 2026-08-15 09:49:13 +08:00
ouyangxq c07af65c3b 优化 2026-08-15 09:49:09 +08:00
ouyangxq 2bf3ff9d8e 优化 2026-08-15 09:49:00 +08:00
ouyangxq 20907e4685 优化 2026-08-15 09:47:17 +08:00
ouyangxq 1982a7dd69 test: align ocean booking fixtures with direct confirmed flow 2026-08-15 09:35:52 +08:00
ouyangxq e4d5e6d631 test: release split bookings before pda 2026-08-14 23:21:47 +08:00
ouyangxq 5bb8f4db6d test: align manual booking list dependencies 2026-08-14 23:14:11 +08:00
ouyangxq 32e9b69556 test(finance): complete automatic fee fixture workflow 2026-08-14 23:03:36 +08:00
ouyangxq 1db7c3b3cf test: align customer archive acceptance status 2026-08-14 22:59:57 +08:00
ouyangxq b89f14217d test: bind real containers before pda push 2026-08-14 22:59:10 +08:00
ouyangxq 0c845874f4 test: distinguish active and void overview photos 2026-08-14 22:43:07 +08:00
ouyangxq 011c30cc09 test: align container flow acceptance identities 2026-08-14 22:30:28 +08:00
ouyangxq 8aa58a4f72 fix(firstleg): hide void photo download paths 2026-08-14 22:29:50 +08:00
ouyangxq b2a093b31b test: release bookings before opening PDA 2026-08-14 22:21:24 +08:00
ouyangxq 010098c4af fix(firstleg): require real container before pda push 2026-08-14 22:03:21 +08:00
ouyangxq fdd011bc57 test: correct container loading acceptance order 2026-08-14 21:45:54 +08:00
ouyangxq dbca4a1403 test: stop container acceptance after failed booking link 2026-08-14 21:37:02 +08:00
ouyangxq 9bb046063c fix(firstleg): execute container customer scope query 2026-08-14 21:31:46 +08:00
ouyangxq 7cb5da753e fix(firstleg): keep truck links on container ownership 2026-08-14 21:14:42 +08:00
ouyangxq a4a4374c67 test(firstleg): cover simulated container booking links 2026-08-14 21:00:20 +08:00
ouyangxq 7184f5083a feat(firstleg): link simulated containers to real bookings 2026-08-14 20:59:03 +08:00
ouyangxq 993b3dfe03 优化 2026-08-14 19:59:26 +08:00
ouyangxq 7d4256dbd2 优化 2026-08-14 19:09:51 +08:00
ouyangxq 10d3390bb1 优化 2026-08-14 18:50:07 +08:00
ouyangxq 9b36978448 修复开发部署 SSH 指纹读取 2026-08-14 17:54:53 +08:00
ouyangxq e33b2c8645 优化 2026-08-14 17:53:09 +08:00
ouyangxq 2752cfb5b2 支持开发环境远程部署 2026-08-14 17:45:43 +08:00
ouyangxq feced8eb92 优化 2026-08-14 17:37:59 +08:00
ouyangxq 87e919f3c4 优化 2026-08-14 16:06:27 +08:00
liuyihe 0192380e81 暂存提交 2026-08-14 15:15:43 +08:00
ouyangxq 87eb1aa72f 优化 2026-08-14 14:54:48 +08:00
ouyangxq 36a048a2bc 优化 2026-08-14 14:50:39 +08:00
ouyangxq 261a8bf2df 优化 2026-08-14 14:26:56 +08:00
ouyangxq 06c264db0a 优化 2026-08-14 13:03:09 +08:00
ouyangxq ce6963a25e 优化 2026-08-14 12:50:37 +08:00
ouyangxq 91e781b3ff 优化 2026-08-14 10:57:05 +08:00
ouyangxq 66089f9b99 优化 2026-08-14 10:09:46 +08:00
ouyangxq 0bc752a2be fix: seed IT customer qualifications 2026-08-14 09:46:50 +08:00
liuyihe 8540147992 Merge branch 'test' into test-lastlag
# Conflicts:
#	auth/src/main/java/com/baiyue/auth/service/AuthService.java
#	auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
2026-08-14 09:24:12 +08:00
ouyangxq db80701534 优化 2026-08-14 09:14:52 +08:00
ouyangxq 274a3f34c5 优化 2026-08-13 20:00:31 +08:00
ouyangxq 5bc62a109e 优化 2026-08-13 20:00:30 +08:00
liuyihe af646c6f1f 暂存提交 2026-08-13 19:49:19 +08:00
ouyangxq 50251240f4 优化 2026-08-13 19:23:44 +08:00
ouyangxq 808fabaf41 优化 2026-08-13 19:19:29 +08:00
liuyihe 073cb6a577 暂存提交 2026-08-13 19:01:16 +08:00
ouyangxq cca1bc002e 优化 2026-08-13 19:00:33 +08:00
ouyangxq 8ba04fc87d 优化 2026-08-13 18:35:19 +08:00
ouyangxq 8cf6709bde 优化 2026-08-13 18:34:44 +08:00
ouyangxq f779ab56dd fix: restore shared HS256 authentication 2026-08-13 17:58:26 +08:00
ouyangxq 9eaaa9a0a0 ci: streamline test environment deployments 2026-08-13 17:40:55 +08:00
ouyangxq 8ff49b501f fix: handle unfiltered domestic container lists 2026-08-13 17:30:03 +08:00
ouyangxq 6ad438e733 优化 2026-08-13 17:24:40 +08:00
ouyangxq 885ff9dc9e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 17:14:54 +08:00
ouyangxq e9564a21a2 优化 2026-08-13 17:14:46 +08:00
liuyihe c43d581ba6 打印箱唛修改 尾程垮桥事件提交 2026-08-13 17:13:58 +08:00
ouyangxq b004edaf9e 1 2026-08-13 17:01:34 +08:00
ouyangxq b780d8bad2 1 2026-08-13 17:01:30 +08:00
ouyangxq 38e8494216 优化 2026-08-13 16:58:17 +08:00
ouyangxq bd523036d0 fix: add inbound fee partner snapshot columns 2026-08-13 16:41:59 +08:00
ouyangxq 6d10f9e502 fix: remove duplicate service center permission counts 2026-08-13 16:23:58 +08:00
ouyangxq 226ef6a839 优化 2026-08-13 16:14:54 +08:00
ouyangxq 8bcd23f307 优化 2026-08-13 15:59:05 +08:00
ouyangxq 8b393d1906 优化 2026-08-13 15:51:27 +08:00
ouyangxq 319900e359 优化 2026-08-13 15:11:01 +08:00
ouyangxq ef9852e757 优化 2026-08-13 14:55:50 +08:00
ouyangxq 939e90912a 优化 2026-08-13 14:38:04 +08:00
ouyangxq 65cd7e0a39 优化 2026-08-13 14:37:51 +08:00
ouyangxq bc62650ca9 优化 2026-08-13 14:03:54 +08:00
ouyangxq 18cebd3fdd 优化 2026-08-13 13:40:15 +08:00
ouyangxq 1df3805334 fix customer statistics mapper scanning 2026-08-13 13:39:36 +08:00
ouyangxq a16b41eb8c fix iam and order deployment test contracts 2026-08-13 13:01:51 +08:00
ouyangxq e5fda0899e fix firstleg deployment contract failures 2026-08-13 12:54:34 +08:00
ouyangxq 756a6b13eb 优化 2026-08-13 12:37:46 +08:00
ouyangxq e742abd1cf 优化 2026-08-13 12:22:31 +08:00
ouyangxq 16dc03a2e2 优化 2026-08-13 11:57:33 +08:00
ouyangxq 5a48992d78 优化 2026-08-13 09:52:59 +08:00
ouyangxq c331133016 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 09:51:29 +08:00
ouyangxq af75569c78 优化 2026-08-13 09:51:27 +08:00
liuyihe 43bbd9c1e8 Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-13 09:51:02 +08:00
liuyihe 74edf76e72 快速下单 2026-08-13 09:50:06 +08:00
ouyangxq 95849f248f 优化 2026-08-13 09:49:55 +08:00
ouyangxq f3366cb2fa 优化 2026-08-12 20:36:50 +08:00
ouyangxq 0ed1531683 优化 2026-08-12 20:36:11 +08:00
ouyangxq 0380c0553b 优化 2026-08-12 20:02:09 +08:00
ouyangxq 30aa0fd945 优化 2026-08-12 19:56:34 +08:00
ouyangxq 8a3f715a3b 优化 2026-08-12 19:51:33 +08:00
ouyangxq 6b1d97af04 优化 2026-08-12 19:47:01 +08:00
ouyangxq 241701d9c5 优化 2026-08-12 19:43:00 +08:00
ouyangxq 47f99befb7 优化 2026-08-12 19:42:08 +08:00
ouyangxq db86e97c0d 优化 2026-08-12 19:30:14 +08:00
ouyangxq e7dfd2790d 优化 2026-08-12 19:14:41 +08:00
ouyangxq af6d348ac3 优化 2026-08-12 19:12:02 +08:00
ouyangxq 9d863500ed 优化 2026-08-12 19:04:45 +08:00
ouyangxq f2f959e8a5 优化 2026-08-12 18:49:46 +08:00
ouyangxq 025467f9ed Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-12 18:23:59 +08:00
ouyangxq ae40d3f173 优化 2026-08-12 18:23:57 +08:00
ouyangxq bac46a0f54 修复PDA稳定柜次查询契约 2026-08-12 18:20:11 +08:00
ouyangxq 4063225352 优化 2026-08-12 18:16:39 +08:00
ouyangxq 5a1cd862c9 优化 2026-08-12 18:00:17 +08:00
ouyangxq 8e8e123d23 优化 2026-08-12 17:45:45 +08:00
ouyangxq b3b8d94957 优化 2026-08-12 17:34:03 +08:00
ouyangxq b5649cf8ae 优化 2026-08-12 17:29:30 +08:00
ouyangxq 4562e3f1d1 优化 2026-08-12 16:58:45 +08:00
ouyangxq 600f3b7861 优化 2026-08-12 16:40:01 +08:00
ouyangxq 1767104406 test finance billed source identity 2026-08-12 16:19:16 +08:00
ouyangxq 0502ffce38 fix finance fee source billed lookup 2026-08-12 16:15:49 +08:00
ouyangxq 6a26b17e0a fix finance acceptance source ownership checks 2026-08-12 15:53:47 +08:00
ouyangxq 645d20665c preserve payable partner in fee snapshots 2026-08-12 15:33:32 +08:00
ouyangxq dca1455478 fix settlement source contracts 2026-08-12 15:21:23 +08:00
ouyangxq 4c7b0e867f fix reconciliation source state transitions 2026-08-12 15:16:24 +08:00
ouyangxq cf3f9c2406 优化 2026-08-12 15:10:40 +08:00
ouyangxq 8c57f5d94e align reconciliation source joins 2026-08-12 15:00:24 +08:00
ouyangxq 633c08045c 优化 2026-08-12 14:56:57 +08:00
ouyangxq 6d50bde31e wait for finance fee pool propagation 2026-08-12 14:56:50 +08:00
ouyangxq 285e17f0ba fix reconciliation source identity 2026-08-12 14:52:53 +08:00
ouyangxq 0d2093054f align finance fixture waybill and pool response 2026-08-12 14:46:52 +08:00
ouyangxq c4e6c28da0 add finance cost allocation detail 2026-08-12 14:40:05 +08:00
ouyangxq fcb7c11af4 优化 2026-08-12 14:37:45 +08:00
ouyangxq d183ad6330 fix container cost snapshot validation 2026-08-12 14:32:48 +08:00
ouyangxq 60895e3bf5 fix finance reconciliation fee pool ids 2026-08-12 14:21:25 +08:00
ouyangxq aaa38f825a 优化 2026-08-12 14:08:29 +08:00
ouyangxq 4375beaa13 补充财务主体缺失阻断诊断 2026-08-12 13:55:16 +08:00
ouyangxq 49967bbc28 test: adapt PDA loading completion contract 2026-08-12 13:48:46 +08:00
ouyangxq 584b73faf5 修复财务整柜成本雪花ID精度 2026-08-12 13:37:47 +08:00
liuyihe c2b1ab92ff 第三期装柜验收优化修改 2026-08-12 13:34:26 +08:00
ouyangxq d2704f68a6 完善财务全量验收链路 2026-08-12 13:24:17 +08:00
ouyangxq 505e834536 优化 2026-08-12 12:50:33 +08:00
ouyangxq d9bbd94366 优化 2026-08-12 12:47:06 +08:00
ouyangxq e4f65dae5f 优化 2026-08-12 12:35:29 +08:00
ouyangxq 0fceece378 优化 2026-08-12 12:18:59 +08:00
ouyangxq 5a26a84f78 优化 2026-08-12 11:25:42 +08:00
ouyangxq 4d0c245b60 优化 2026-08-12 10:59:39 +08:00
ouyangxq 91c6ebca5c fix(firstleg): map PDA query rows to concrete beans 2026-08-12 10:37:50 +08:00
ouyangxq b90470af97 优化 2026-08-12 09:40:50 +08:00
ouyangxq 1cb4fba276 优化 2026-08-12 09:25:53 +08:00
ouyangxq b2af5739c8 优化 2026-08-11 20:39:28 +08:00
ouyangxq 86488da6d0 test(admin): verify PDA inventory snapshots 2026-08-11 20:10:37 +08:00
ouyangxq 5e1e281773 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 20:02:34 +08:00
ouyangxq d7d33c4e9b 优化 2026-08-11 20:02:33 +08:00
ouyangxq 1f4b09f0c9 test(admin): align fee item acceptance payload 2026-08-11 20:02:04 +08:00
liuyihe bed917ba6c 装柜修改 2026-08-11 20:00:31 +08:00
ouyangxq 48bb752007 优化 2026-08-11 19:59:06 +08:00
ouyangxq 7c24c96f88 fix(firstleg): wire finance outbox relay constructor 2026-08-11 19:47:20 +08:00
ouyangxq 3809d2a028 优化 2026-08-11 19:35:18 +08:00
ouyangxq 4d49d7a2e4 fix(deploy): synchronize finance fee source migration 2026-08-11 18:53:04 +08:00
ouyangxq bc5d9a6215 优化 2026-08-11 18:43:50 +08:00
ouyangxq e1487162a5 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 18:17:00 +08:00
ouyangxq 7e52d0c025 优化 2026-08-11 18:16:59 +08:00
ouyangxq 8a65f18fce test(admin): complete phase3 producer fixtures 2026-08-11 18:07:59 +08:00
ouyangxq 47c1c7922f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 18:06:28 +08:00
ouyangxq 192964419f 优化 2026-08-11 18:06:26 +08:00
ouyangxq cad2ba6e91 test(admin): align phase3 loading producer contract 2026-08-11 17:48:36 +08:00
ouyangxq a02dd3a422 test(admin): align phase3 loading producer contract 2026-08-11 17:47:19 +08:00
ouyangxq a59f4634cd fix(firstleg): allow preload confirmation after loading starts 2026-08-11 15:25:49 +08:00
ouyangxq 16a2f83da6 fix(firstleg): start loading when PDA task is claimed 2026-08-11 15:20:41 +08:00
ouyangxq 4561245a71 test(admin): align loading state transition assertions 2026-08-11 15:11:50 +08:00
ouyangxq 386eae6785 test(admin): follow real loading start sequence 2026-08-11 15:11:05 +08:00
liuyihe cb30839945 装柜修改 2026-08-11 14:24:05 +08:00
ouyangxq bf7c214908 fix(firstleg): execute loading completion gate SQL 2026-08-11 14:13:28 +08:00
ouyangxq df0f05c74d test(admin): keep phase3 producer fixtures aligned 2026-08-11 14:07:16 +08:00
ouyangxq 7c2274be31 优化 2026-08-11 13:55:16 +08:00
ouyangxq 40171b7047 fix(firstleg): allow PDA batch operation audit 2026-08-11 11:53:32 +08:00
ouyangxq 3f301e390b test(admin): accept customs source-carried loading slices 2026-08-11 11:49:10 +08:00
ouyangxq 29c010e28f test(admin): retain PDA batch gap until verified 2026-08-11 11:46:23 +08:00
ouyangxq 9b3ff06a90 优化 2026-08-11 11:41:41 +08:00
ouyangxq 9f455b6c0b feat(firstleg): add atomic PDA batch picking endpoint 2026-08-11 11:24:59 +08:00
ouyangxq 7aa2b507f6 fix(firstleg): retry chained remainder settlement 2026-08-11 11:12:29 +08:00
ouyangxq fe0f1665e5 test(admin): align phase3 PDA direct completion fixtures 2026-08-11 11:11:57 +08:00
ouyangxq 166ebfb7ef 优化 2026-08-11 10:46:03 +08:00
ouyangxq c488c8aa02 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 10:45:55 +08:00
ouyangxq e4372783d0 优化 2026-08-11 10:45:53 +08:00
ouyangxq 2de3fb36e9 fix(gateway): include stable request and trace ids in security errors 2026-08-11 10:37:17 +08:00
ouyangxq 4e79287a35 fix(gateway): include stable request and trace ids in security errors 2026-08-11 10:35:01 +08:00
ouyangxq 5f90bf3bc8 fix(order): inherit cargo bindings for remainder orders 2026-08-11 10:11:30 +08:00
ouyangxq 0e47155649 test(admin): follow direct PDA completion contract 2026-08-11 10:10:18 +08:00
ouyangxq 77a91b6553 fix(order): inherit cargo bindings for remainder orders 2026-08-11 10:09:09 +08:00
ouyangxq f5c424b36d test(admin): verify chained remainder fulfillment splits 2026-08-11 10:02:41 +08:00
ouyangxq bceaff496e fix(firstleg): make scanned shipment id optional 2026-08-11 09:57:45 +08:00
ouyangxq dfc1db6e8c test(firstleg): align single-remainder completion contracts 2026-08-11 09:31:43 +08:00
ouyangxq 22cebaaa7a test(order): guard cross-role fulfillment split uniqueness 2026-08-11 09:28:23 +08:00
ouyangxq e6eba04eda Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-11 09:25:43 +08:00
ouyangxq 540a6f5d88 优化 2026-08-11 09:25:41 +08:00
ouyangxq 66b3a0cc9b feat(order): adopt single-remainder fulfillment splits 2026-08-11 09:22:03 +08:00
ouyangxq fe60a20d95 feat(firstleg): enforce single remainder split schema 2026-08-11 09:18:03 +08:00
ouyangxq 39b9f237e0 test(phase3): verify single-remainder order flow 2026-08-11 09:17:39 +08:00
ouyangxq 5913c3c3ef feat(firstleg): enforce single remainder split schema 2026-08-11 09:17:02 +08:00
liuyihe e915086133 装柜修改 2026-08-10 20:52:46 +08:00
ouyangxq 4630186a56 fix(firstleg): accept unsplit inbound package totals 2026-08-10 20:26:41 +08:00
ouyangxq b1ca37699f fix(firstleg): sequence child inbound codes 2026-08-10 20:18:53 +08:00
ouyangxq 4749bd3bc1 test(warehouse): cover PDA occupancy package counts 2026-08-10 18:44:04 +08:00
ouyangxq 86632ef383 优化 2026-08-10 18:40:43 +08:00
ouyangxq efcd046853 fix(firstleg): make PDA allocation settlement idempotent 2026-08-10 18:17:53 +08:00
ouyangxq a4c47c281c 优化 2026-08-10 18:12:18 +08:00
ouyangxq 3bfc11a94d fix(firstleg): defer PDA allocation release transition 2026-08-10 18:00:24 +08:00
ouyangxq 84209bffd7 fix(phase3): close loading and dispatch flows 2026-08-10 17:42:17 +08:00
ouyangxq 074a95d82b fix(warehouse): allow platform resource scope 2026-08-10 16:34:11 +08:00
ouyangxq 59a4eb8ed5 优化 2026-08-10 16:24:47 +08:00
ouyangxq 9ab7c01639 fix(sql): make fee migration MySQL-compatible 2026-08-10 15:52:40 +08:00
ouyangxq c9f1fe0093 优化 2026-08-10 15:36:47 +08:00
ouyangxq 013de5e921 fix(firstleg): instantiate PDA progress projection 2026-08-10 14:31:03 +08:00
ouyangxq a81f73d835 test(phase3): verify merchant fulfillment scope 2026-08-10 14:18:24 +08:00
ouyangxq ce25a0a72d test(phase3): verify fulfillment aggregates and update schedule gaps 2026-08-10 14:15:04 +08:00
ouyangxq d42392dfc5 test: format port schedule fixtures in Shanghai local time 2026-08-10 14:06:56 +08:00
ouyangxq 55060c7cbe fix(sql): use mysql8 compatible PDA migrations 2026-08-10 13:54:28 +08:00
ouyangxq d2198fff06 fix(phase3): close inbound split trigger condition 2026-08-10 13:51:13 +08:00
ouyangxq d15bc9b583 fix(deploy): restore unified migration final assertions 2026-08-10 13:45:51 +08:00
ouyangxq ac085e0f53 chore(deploy): synchronize phase3 unified database migration 2026-08-10 13:31:42 +08:00
ouyangxq 88390f9401 feat(order): add container cross-order fulfillment aggregation 2026-08-10 13:16:40 +08:00
ouyangxq 3c44f3bd51 Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-10 13:07:15 +08:00
ouyangxq 253c0e7472 feat(phase3): connect PDA settlement to real child orders 2026-08-10 13:02:51 +08:00
ouyangxq 20996b050b fix(iam): 修复仓库角色统一迁移执行 2026-08-10 13:00:13 +08:00
ouyangxq 64ba5ae249 fix(phase3): route carrier queries and repair schedule updates 2026-08-10 12:56:02 +08:00
liuyihe a1bcb519c9 装柜只能限制一个人拣货 2026-08-10 12:50:22 +08:00
ouyangxq ea1346ee2c feat(order): expose fulfillment split contract in openapi 2026-08-10 12:49:15 +08:00
ouyangxq 986e97acb6 优化 2026-08-10 12:46:12 +08:00
ouyangxq fc554273b9 优化 2026-08-10 12:40:47 +08:00
ouyangxq 7684f3cfec test(iam): verify authorization session revocation 2026-08-10 12:37:46 +08:00
ouyangxq 14929bb257 优化 2026-08-10 12:27:16 +08:00
ouyangxq eab51bc5ac Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-10 12:27:09 +08:00
ouyangxq 74ce1358ae 优化 2026-08-10 12:24:01 +08:00
ouyangxq 75d5fd4e26 feat(phase3): 收口父子单与全链路验收 2026-08-10 12:23:43 +08:00
ouyangxq b294695169 fix(iam): 收敛仓库角色权限并纳入统一迁移 2026-08-10 12:22:29 +08:00
ouyangxq edd5108625 feat(firstleg): add compliant carrier schedule queries 2026-08-10 12:20:05 +08:00
ouyangxq 6974aeaaeb fix(deploy): allow expanded authorization headers 2026-08-10 12:07:19 +08:00
ouyangxq 5c1c6a1a07 fix(deploy): allow expanded authorization headers 2026-08-10 12:07:03 +08:00
ouyangxq 1200ef54ad feat(iam): 初始化三期仓库作业角色 2026-08-10 11:54:58 +08:00
liuyihe 037527b4cb 装柜回归测试 2026-08-10 11:30:14 +08:00
ouyangxq 047f267d4f 优化 2026-08-10 11:28:57 +08:00
ouyangxq e33fb923e4 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-10 09:11:57 +08:00
ouyangxq 06db4e3432 优化 2026-08-10 09:11:56 +08:00
liuyihe 0ce264a257 装柜回归测试 2026-08-09 20:35:11 +08:00
ouyangxq b20cd9bd77 chore: stop tracking generated reports 2026-08-09 11:25:59 +08:00
ouyangxq b1cd1b059d test(firstleg): mark phase3 gaps pending deployment 2026-08-09 11:25:54 +08:00
ouyangxq ed5b9fe77f feat(firstleg): converge phase3 loading models and pda 2026-08-09 11:21:38 +08:00
ouyangxq 9b88fb4b9e test(firstleg): cover phase3 outbound contracts 2026-08-08 22:42:49 +08:00
ouyangxq 66d1aa0d3d feat(firstleg): close phase3 outbound customs and schedule gates 2026-08-08 22:36:41 +08:00
ouyangxq 789d97675b fix(firstleg): persist ocean container allocation 2026-08-08 21:49:41 +08:00
ouyangxq da85e30f33 test(firstleg): cover phase3 container loading flow 2026-08-08 21:34:39 +08:00
ouyangxq 2dae8b2029 优化 2026-08-08 21:14:39 +08:00
ouyangxq e5c6e03033 test(admin): stabilize service center acceptance fixtures 2026-08-08 20:47:26 +08:00
ouyangxq aab8e37175 refactor(file): isolate truck attachment storage 2026-08-08 20:45:10 +08:00
ouyangxq 6b0781d120 fix(file): support truck appointment attachments 2026-08-08 20:43:35 +08:00
ouyangxq f37ad6d83d test(admin): accept explicit duplicate import rejection 2026-08-08 20:42:34 +08:00
ouyangxq f239bb739b 优化 2026-08-08 20:39:49 +08:00
ouyangxq 0558f7ae59 优化 2026-08-08 20:36:23 +08:00
ouyangxq 57fcabe740 优化 2026-08-08 15:57:04 +08:00
ouyangxq d49b4128bc fix(warehouse): reject invalid import files with validation code 2026-08-08 15:54:31 +08:00
ouyangxq 931009a518 优化 2026-08-08 15:25:46 +08:00
ouyangxq 7b2b208467 test: expand overseas storage fee acceptance 2026-08-08 15:14:28 +08:00
ouyangxq e395292cd5 优化 2026-08-08 15:05:33 +08:00
ouyangxq 102845874b fix(firstleg): consolidate service center migration contract 2026-08-08 14:33:25 +08:00
ouyangxq 355b8629d9 优化 2026-08-08 14:26:02 +08:00
ouyangxq 13a2b90e98 优化 2026-08-08 14:19:45 +08:00
ouyangxq 87c1bfbb71 修复 2026-08-08 14:17:06 +08:00
ouyangxq 87508bba82 优化 2026-08-08 14:13:28 +08:00
ouyangxq ef5878a85d 优化 2026-08-08 13:57:07 +08:00
ouyangxq b3d6f865b7 fix: scan ocean booking mappers at startup 2026-08-08 13:56:16 +08:00
ouyangxq f690abbaeb 优化 2026-08-08 13:51:28 +08:00
ouyangxq 374ceed493 优化 2026-08-08 13:46:16 +08:00
ouyangxq 3203adb5a7 优化 2026-08-08 13:32:20 +08:00
ouyangxq 96788e10de test: lock weighted Mojia carton totals 2026-08-08 13:18:31 +08:00
ouyangxq b6b5e2dc55 fix(audit): exclude authenticated principal from snapshots 2026-08-08 13:15:01 +08:00
ouyangxq dca8c05d33 test(admin): verify weighted Mojia measurement volume 2026-08-08 13:08:24 +08:00
ouyangxq 44bae99999 fix(firstleg): avoid phase-three reads in phase-two queries 2026-08-08 12:25:00 +08:00
ouyangxq d30e975eed 优化 2026-08-08 12:16:01 +08:00
ouyangxq 08f71cf17e test: cover all customer dimension weighting rules 2026-08-08 12:04:30 +08:00
ouyangxq a35cd868b9 优化 2026-08-08 12:01:45 +08:00
ouyangxq 1a2f598c9a fix(warehouse): keep partner query record bindable 2026-08-08 11:51:52 +08:00
ouyangxq 7c0f2fc5f5 test: keep Mojia samples within customer box limits 2026-08-08 11:50:55 +08:00
ouyangxq 0113d19274 fix: expose phase2 terminal inbound billing 2026-08-08 11:46:20 +08:00
ouyangxq ef4e1d6015 fix: gate device scans by actual measurement submission state 2026-08-08 11:39:28 +08:00
ouyangxq d6ed4a5ad7 test(warehouse): cover unknown partner type list validation 2026-08-08 11:37:21 +08:00
ouyangxq 31d18abbf0 优化 2026-08-08 11:34:03 +08:00
ouyangxq 04c391333b 优化 2026-08-08 11:33:25 +08:00
ouyangxq 74e42e91b6 优化 2026-08-08 11:33:20 +08:00
ouyangxq 92b3b9b1fb 优化 2026-08-08 11:33:18 +08:00
ouyangxq 2ee78aafc2 fix: record oversize device measurements for review 2026-08-08 11:33:02 +08:00
ouyangxq 54893aeb6b 优化 2026-08-08 11:07:23 +08:00
ouyangxq 2644718b5f 优化 2026-08-08 10:31:44 +08:00
ouyangxq ced35f1600 fix: 补齐客户操作权限数据库迁移 2026-08-08 10:30:39 +08:00
ouyangxq 77aa5a845e fix: 允许匿名兑换客户代登录授权码 2026-08-08 10:22:41 +08:00
ouyangxq 5f19cdcccb 优化 2026-08-08 10:18:29 +08:00
ouyangxq 4f48015b81 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 10:17:50 +08:00
ouyangxq 38e7094520 修复订单审核后履约任务未生成 2026-08-08 10:17:46 +08:00
ouyangxq 73bfc5a65c 优化 2026-08-08 10:17:43 +08:00
ouyangxq dd8147c535 fix: carry package count into inbound snapshot 2026-08-08 10:16:33 +08:00
ouyangxq 71fde99ceb Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 10:11:05 +08:00
ouyangxq fc14856ea3 优化 2026-08-08 10:11:04 +08:00
ouyangxq 0c37d40cf1 修复已入库费用发送状态回退 2026-08-08 09:53:39 +08:00
ouyangxq 2779bf9ff7 修复客户尺寸加权与入库计费验收 2026-08-08 09:43:56 +08:00
ouyangxq f3c2bafec8 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-08 09:17:44 +08:00
ouyangxq 5951e6aa68 优化 2026-08-08 09:17:42 +08:00
liuyihe 0d70edc020 Merge branch 'test-02huogui' into test
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java
2026-08-07 23:46:44 +08:00
liuyihe a09639ff05 暂存提交 2026-08-07 23:43:41 +08:00
ouyangxq 4a6f976a91 优化 2026-08-07 20:08:13 +08:00
ouyangxq c4b0972dc9 优化 2026-08-07 20:07:30 +08:00
liuyihe 2b81fd40e7 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
2026-08-07 19:49:09 +08:00
liuyihe 3f15f3ad2b 暂存提交 2026-08-07 19:40:42 +08:00
ouyangxq c698290106 优化 2026-08-07 19:23:40 +08:00
ouyangxq 37fec1e012 优化 2026-08-07 18:44:51 +08:00
ouyangxq 1a76602872 优化 2026-08-07 18:03:56 +08:00
ouyangxq 645c483649 优化 2026-08-07 17:46:23 +08:00
ouyangxq 98ac9840c2 优化 2026-08-07 17:36:02 +08:00
ouyangxq 91f31d9567 优化 2026-08-07 17:14:40 +08:00
ouyangxq f4c3a95772 优化 2026-08-07 16:16:59 +08:00
ouyangxq ba5bad4429 优化 2026-08-07 16:02:42 +08:00
ouyangxq 3f597677fe 优化 2026-08-07 15:53:55 +08:00
liuyihe 7c17b1a76a 订单批量审核提交 2026-08-07 15:32:59 +08:00
ouyangxq a4a03a8ae8 优化 2026-08-07 15:19:21 +08:00
liuyihe 77dc32a69a 订单批量审核提交 2026-08-07 14:52:21 +08:00
ouyangxq b0ea1a0c5c 优化 2026-08-07 14:46:55 +08:00
liuyihe 0dc60d835b 装柜 2026-08-07 12:19:47 +08:00
liuyihe efb01f8932 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java
#	firstleg/src/main/resources/openapi/firstleg-v1.yaml
2026-08-07 11:11:43 +08:00
liuyihe ebea3a1813 装柜 2026-08-07 10:57:06 +08:00
ouyangxq 830575cc92 优化 2026-08-07 10:45:09 +08:00
ouyangxq 897f91af20 优化 2026-08-07 09:06:19 +08:00
ouyangxq a5dc0f78c3 优化 2026-08-06 19:21:09 +08:00
ouyangxq 6964e3afa3 优化 2026-08-06 19:03:09 +08:00
ouyangxq 74db8e165d 优化 2026-08-06 18:34:52 +08:00
ouyangxq d2e3fef8f8 优化 2026-08-06 18:14:29 +08:00
ouyangxq 6e9739826c 优化 2026-08-06 18:01:59 +08:00
liuyihe 913eca7b7d 装柜 2026-08-06 17:48:50 +08:00
ouyangxq 584f1519d1 优化 2026-08-06 16:58:33 +08:00
ouyangxq 63eefd280b 优化 2026-08-06 16:17:16 +08:00
liuyihe c68eedd3f3 Merge branch 'test' into test-02huogui
# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
2026-08-06 15:54:25 +08:00
liuyihe c3a978b497 提交 2026-08-06 15:48:21 +08:00
ouyangxq 9c4bae9f5c 优化 2026-08-06 15:43:35 +08:00
ouyangxq 3ecb8d7613 优化 2026-08-06 15:36:23 +08:00
ouyangxq 5f8b1f64da 优化 2026-08-06 15:32:11 +08:00
ouyangxq f3cfbdf59b 优化 2026-08-06 14:55:24 +08:00
ouyangxq dca3a48284 优化 2026-08-06 13:56:20 +08:00
ouyangxq e6604ed680 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-06 11:49:53 +08:00
ouyangxq 7dd92f1c84 优化 2026-08-06 11:49:51 +08:00
liuyihe 4df8b8c355 客户运费计费 2026-08-05 20:32:43 +08:00
ouyangxq a11e47fe3b 1 2026-08-05 18:02:56 +08:00
ouyangxq 5ba9716e12 优化 2026-08-05 16:26:10 +08:00
liuyihe 06f7f9d2f2 日志记录提交 2026-08-05 14:20:04 +08:00
ouyangxq 264458920b 优化 2026-08-05 13:50:37 +08:00
ouyangxq 487f398eb8 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-05 11:46:07 +08:00
ouyangxq 91c276f520 1 2026-08-05 11:46:05 +08:00
liuyihe cac377de96 新增海外仓阶梯费用 2026-08-05 11:20:28 +08:00
liuyihe 9678ee0f1f 删除增量文件 2026-08-05 10:54:38 +08:00
liuyihe 496fc58a90 格式修改 2026-08-05 10:35:07 +08:00
ouyangxq 81c9c92f5d 优化 2026-08-05 09:30:08 +08:00
ouyangxq 3791fee285 优化 2026-08-05 09:11:26 +08:00
ouyangxq d544bbd0c5 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-04 21:02:01 +08:00
ouyangxq 9b7b08665e 优化 2026-08-04 21:01:53 +08:00
liuyihe de8acc419f 二期内容提交:客户端订单列表 2026-08-04 20:49:46 +08:00
ouyangxq 7e544780b4 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-04 20:15:56 +08:00
ouyangxq 09c5942e83 更新网关 2026-08-04 20:15:55 +08:00
liuyihe 917f217088 二期内容提交 2026-08-04 20:13:56 +08:00
liuyihe e20d20c540 二期内容提交 2026-08-04 20:13:40 +08:00
liuyihe c3bf4edd53 二期内容提交 2026-08-04 19:48:35 +08:00
ouyangxq 954adb1287 Merge branch 'test-01' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 18:54:23 +08:00
ouyangxq dc2f844467 打印服务 2026-08-04 18:54:22 +08:00
liuyihe 3292f2c597 二期内容提交 2026-08-04 17:29:36 +08:00
liuyihe b7d36d4766 二期内容提交 2026-08-04 17:18:07 +08:00
ouyangxq db72bdf884 Merge branch 'test-01' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 15:41:21 +08:00
ouyangxq f1dcb91fa9 添加AGENTS.md 快速索引 2026-08-04 15:41:19 +08:00
liuyihe 2b712f9cdf Merge remote-tracking branch 'origin/test-01' into test-01 2026-08-04 14:56:12 +08:00
liuyihe 656f05d3c6 二期内容提交 2026-08-04 14:55:58 +08:00
liuyihe 4d8b31429c Merge remote-tracking branch 'origin/test' into test-01 2026-08-04 14:55:52 +08:00
ouyangxq f330098ff6 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 14:54:57 +08:00
ouyangxq cd540f9b3e 优化 2026-08-04 14:15:55 +08:00
ouyangxq 694425690c 优化 2026-08-04 13:32:24 +08:00
ouyangxq 434715b938 优化 2026-08-04 12:57:58 +08:00
liuyihe 7e945229be Merge remote-tracking branch 'origin/test' into test-01
# Conflicts:
#	config/nacos/cn/file-service.yml
#	config/nacos/local/file-service.yml
#	deploy/sql/README.md
#	firstleg/pom.xml
2026-08-04 12:27:25 +08:00
ouyangxq 667e0be7bd 修复bug 2026-08-04 12:00:38 +08:00
ouyangxq dc3fda34c9 优化 2026-08-04 11:21:58 +08:00
ouyangxq da9aa9515e 优化 2026-08-04 11:14:42 +08:00
ouyangxq 0fe129bba7 优化 2026-08-04 09:43:53 +08:00
ouyangxq 8ada1e5714 修复单服务部署被MQ配置阻断 2026-08-04 09:11:07 +08:00
ouyangxq 3afcff320a 优化 2026-08-04 09:05:28 +08:00
ouyangxq fdb1e8af1c 优化 2026-08-03 18:28:35 +08:00
2362 changed files with 231013 additions and 7879 deletions
+6
View File
@@ -1,4 +1,5 @@
target/ target/
node_modules/
.docker-context/ .docker-context/
*.log *.log
.idea/ .idea/
@@ -23,4 +24,9 @@ dependency-reduced-pom.xml
reports/runtime*/ reports/runtime*/
reports/uploads/ reports/uploads/
reports/db-backup*/ reports/db-backup*/
reports/
uploads/ uploads/
.codex-temp*
.codex-tmp*
.codex-*.out
.codex-*.err
+57 -1
View File
@@ -13,6 +13,55 @@ cache:
paths: paths:
- .m2/repository/ - .m2/repository/
Java拓扑合同:
stage: verify
image: node:22-alpine
cache: []
script:
- node --test scripts/java-topology-contract.test.mjs
OpenAPI有效性:
stage: verify
image: node:22-alpine
cache: []
script:
- npm ci --ignore-scripts --prefix scripts/openapi-validation
- npm test --prefix scripts/openapi-validation
- npm run validate --prefix scripts/openapi-validation
静态分析报告:
stage: verify
allow_failure: true
script:
- mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify
artifacts:
when: always
expire_in: 7 days
paths:
- "**/target/spotbugs*.xml"
- "**/target/site/spotbugs.html"
质量摘要:
stage: verify
image: node:22-alpine
cache: []
allow_failure: true
needs:
- job: OpenAPI有效性
artifacts: false
- job: 校验与测试
artifacts: true
- job: 静态分析报告
artifacts: true
script:
- node --test scripts/java-quality-summary.test.mjs
- node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs
artifacts:
when: always
expire_in: 7 days
paths:
- target/java-quality-summary.json
校验与测试: 校验与测试:
stage: verify stage: verify
script: script:
@@ -25,10 +74,14 @@ cache:
- "**/target/surefire-reports/TEST-*.xml" - "**/target/surefire-reports/TEST-*.xml"
paths: paths:
- "**/target/surefire-reports/" - "**/target/surefire-reports/"
- "**/target/site/jacoco/"
- "**/target/jacoco.exec"
打包服务: 打包服务:
stage: package stage: package
needs: needs:
- Java拓扑合同
- OpenAPI有效性
- 校验与测试 - 校验与测试
script: script:
- mvn $MAVEN_CLI_OPTS -DskipTests package - mvn $MAVEN_CLI_OPTS -DskipTests package
@@ -36,14 +89,17 @@ cache:
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
- firstleg/target/*.jar - firstleg/target/*.jar
- finance/target/*.jar
- warehouse/target/*.jar - warehouse/target/*.jar
- warehouse-overseas-service/target/*.jar
- lastleg-service/target/*.jar
- file/target/*.jar - file/target/*.jar
- 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 - event-bridge-service/target/*.jar
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
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.
+93 -59
View File
@@ -1,73 +1,107 @@
# 柏悦头程后端项目指引 # 头程与海外仓后端项目指引
## 适用范围 ## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界以本文件及该说明的“项目定位、主要调用关系、开发和拆分约束”为准。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 模块快速索引 ## 不可突破的产品与数据边界
| 模块 | 职责 | 首选入口 | - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
| 任务关键词 | 首选服务 | 先看入口 |
|---|---|---| |---|---|---|
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` | | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` | | 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`TenantOrderCountResponse.java`、`README-ORDER.md` | | 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| `warehouse/` | 仓库、地址、服务能力、往来单位多类型、快递公司与 XLSX 导入 | `controller/`、`PlatformPartnerQueryService.java`、`PartnerImportService.java`、`ExpressCompanyImportService.java` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| `file/` | 企业资质文件上传、替换、预览与鉴权 | `QualificationFileController.java`、`PlatformQualificationFileController.java`、`LocalQualificationStorage.java`;元数据仍归 IAM,File 自身表结构尚未接入 | | 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | | 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | | 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` | | 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
| 海外仓 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/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 |
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
## 高频业务检索入口 ## 公共模块快速索引
| 搜索目标 | 先读 | | 关键词 | 模块与入口 |
|---|---| |---|---|
| 后台用户管理、平台/企业账号、跨租户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 | | 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 |
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` | | 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 |
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 | | Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 |
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` | | JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` | | Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` | | 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` | | 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
## 配置、数据库与验证入口 ## 全量验收路由
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。 - 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。 - `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
## 通用修改约束 ## 国内头程与海外仓协同
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。 - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。 - 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。 - 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。 - 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
## 本地内部服务令牌 ## 契约、数据库与验证入口
- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。 - 修改 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`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
## 阶段文档入口
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+216 -48
View File
@@ -1,33 +1,112 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-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',
'finance': 'SERVICE_FINANCE',
'order': 'SERVICE_ORDER', 'order': 'SERVICE_ORDER',
'warehouse': 'SERVICE_WAREHOUSE', 'warehouse': 'SERVICE_WAREHOUSE',
'file': 'SERVICE_FILE', 'file': 'SERVICE_FILE',
'print': 'SERVICE_PRINT', 'print': 'SERVICE_PRINT',
'integration-service': 'SERVICE_INTEGRATION', 'integration-service': 'SERVICE_INTEGRATION',
'lastleg-service': 'SERVICE_LASTLEG',
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
'audit-service': 'SERVICE_AUDIT' 'audit-service': 'SERVICE_AUDIT'
] ]
def commonPaths = [ def fullRebuildPaths = [
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/', 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile', 'deploy/docker-compose.yml', 'deploy/service-map.yml'
'Jenkinsfile.service', 'deploy/'
] ]
def commonPathServices = [
'common-web/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-security/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'lastleg-service', 'warehouse-overseas-service', 'audit-service'
],
'common-openapi/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'lastleg-service', 'warehouse-overseas-service',
'event-bridge-service', 'audit-service'
],
'common-audit/': [
'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file',
'warehouse-overseas-service', 'audit-service'
],
'common-event-contract/': [
'firstleg', 'iam', 'lastleg-service', 'event-bridge-service'
],
'deploy/docker-compose.overseas.yml': [
'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service'
],
'deploy/docker-compose.audit.yml': ['audit-service'],
'deploy/docker-compose.rocketmq.yml': ['event-bridge-service']
]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:] def downstreamResults = [:]
def parseAutoBuildState(String description, List<String> allowedServices) {
String marker = ' | RESULTS'
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
int markerIndex = description.indexOf(marker)
String commit = description.substring('AUTO '.length(), markerIndex).trim()
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
String summary = description.substring(markerIndex + marker.length()).trim()
List<String> failedServices = []
if (summary) {
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
int separator = item.indexOf('=')
if (separator > 0) {
String service = item.substring(0, separator).trim()
String result = item.substring(separator + 1).trim()
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
}
}
}
return [commit: commit, failedServices: failedServices.unique()]
}
def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
def candidate = previousBuild
int inspected = 0
while (candidate != null && inspected < 30) {
def state = parseAutoBuildState(candidate.description, allowedServices)
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
candidate = candidate.previousBuild
inspected++
}
return null
}
def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev'
? ['development', 'test', 'staging', 'production']
: ['test', 'development', 'staging', 'production']
}
def downstreamJobPrefixForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev'
? 'baiyue-firstleg-dev-'
: 'baiyue-firstleg-'
}
pipeline { pipeline {
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。 // 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
agent any agent none
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
@@ -38,42 +117,50 @@ 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_ORDER', defaultValue: false, description: '订单服务(order)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)') booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)') booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)')
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], 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: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/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 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth') string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
} }
stages { stages {
stage('确定构建范围') { stage('确定构建范围') {
agent { label 'java-builder' }
steps { steps {
script { script {
String requestedRepository // 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
if (params.ENVIRONMENT == 'test') { // 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
requestedRepository = 'baiyue-firstleg-test' String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry' ? 'development'
} else { : params.ENVIRONMENT
requestedRepository = params.IMAGE_REPOSITORY?.trim() env.RESOLVED_ENVIRONMENT = resolvedEnvironment
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
error('预发布/生产环境必须填写真实镜像仓库地址')
} String requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) { if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://') error('必须填写真实镜像仓库地址')
}
} }
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
}
echo "${env.RESOLVED_ENVIRONMENT} 环境使用共享镜像仓库: ${requestedRepository}"
env.RESOLVED_IMAGE_REPOSITORY = requestedRepository env.RESOLVED_IMAGE_REPOSITORY = requestedRepository
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim() String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
@@ -81,7 +168,13 @@ pipeline {
error('DOWNSTREAM_JOB_PREFIX 不能为空') error('DOWNSTREAM_JOB_PREFIX 不能为空')
} }
env.RESOLVED_JOB_PREFIX = jobPrefix env.RESOLVED_JOB_PREFIX = jobPrefix
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
@@ -93,18 +186,45 @@ pipeline {
error('SELECT 模式至少需要勾选一个服务') error('SELECT 模式至少需要勾选一个服务')
} }
} else { } else {
String base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim() def autoState = previousAutoBuildState(currentBuild.previousBuild, serviceModules)
String base = autoState?.commit?.trim()
List retryServices = autoState?.failedServices ?: []
if (!base) {
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
// SELECT/ALL 不会推进 AUTO 基线。
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
}
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
echo 'AUTO 未找到可用基线,安全回退为全量构建'
} else { } else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it } .trim().split('\\n').collect { it.trim() }.findAll { it }
boolean commonChanged = changed.any { path -> boolean databaseChanged = changed.any { path ->
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
} }
selected = commonChanged ? serviceModules : serviceModules.findAll { module -> env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
changed.any { it == module || it.startsWith("${module}/") } List serviceRelevantChanges = changed.findAll { path ->
!databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
} }
boolean fullRebuild = serviceRelevantChanges.any { path ->
fullRebuildPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
if (fullRebuild) {
selected = serviceModules
} else {
selected = serviceModules.findAll { module ->
serviceRelevantChanges.any { it == module || it.startsWith("${module}/") }
}
commonPathServices.each { prefix, impactedServices ->
if (serviceRelevantChanges.any { path -> path == prefix || path.startsWith(prefix) }) {
selected.addAll(impactedServices)
}
}
}
selected.addAll(retryServices)
echo "AUTO 基线: ${base};变更选中: ${selected.unique().join(',') ?: '无'};" +
"补跑失败服务: ${retryServices.join(',') ?: '无'}"
} }
} }
@@ -114,16 +234,37 @@ 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) {
currentBuild.description = '没有需要构建的业务服务' currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS"
: (env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务')
} else { } else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() currentBuild.description = params.BUILD_MODE == 'AUTO'
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}" ? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
} }
if (env.DATABASE_CHANGE_DETECTED == 'true') {
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
}
} }
} }
} }
@@ -131,7 +272,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') { stage('生产环境部署确认(仅 production 执行)') {
when { when {
allOf { allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
} }
} }
steps { steps {
@@ -139,14 +280,30 @@ pipeline {
} }
} }
stage('测试数据库初始化(可选)') { stage('统一 Maven 测试门禁') {
when { when {
allOf { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } }
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
steps {
script {
String selectedModules = env.SELECTED_SERVICES.split(',')
.collect { it.trim() }
.findAll { it }
.join(',')
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
"""
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
} }
} }
steps { post {
sh 'sh deploy/scripts/initialize-test-databases.sh' always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
} }
} }
@@ -159,6 +316,14 @@ 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,
@@ -166,12 +331,16 @@ 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: env.IMAGE_TAG), string(name: 'IMAGE_TAG', value: downstreamImageTag),
string(name: 'ENVIRONMENT', value: params.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: params.SKIP_TESTS), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。
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'
@@ -191,7 +360,9 @@ pipeline {
.collect { service, result -> "${service}=${result}" } .collect { service, result -> "${service}=${result}" }
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ') String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
echo "单服务执行汇总: ${summary}" echo "单服务执行汇总: ${summary}"
currentBuild.description = "${env.IMAGE_TAG} | ${summary}" currentBuild.description = params.BUILD_MODE == 'AUTO'
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
: "${env.IMAGE_TAG} | ${summary}"
if (failed) { if (failed) {
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
} }
@@ -200,7 +371,4 @@ pipeline {
} }
} }
post {
always { cleanWs(deleteDirs: true, notFailBuild: true) }
}
} }
+214 -96
View File
@@ -1,29 +1,29 @@
def serviceModules = [ def serviceModules = [
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print', 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
'integration-service', 'event-bridge-service', 'audit-service' 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-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',
'finance': 'finance/target/finance-service-1.0.0-SNAPSHOT.jar',
'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar', 'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar',
'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar', 'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar',
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
'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',
'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', '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'
] ]
pipeline { pipeline {
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。 // 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent any agent none
tools {
maven 'maven-3.8.8'
}
options { options {
skipDefaultCheckout(true) skipDefaultCheckout(true)
@@ -35,110 +35,202 @@ 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', 'staging', 'production'], description: '部署环境') choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], 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: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
} }
environment { environment {
REGISTRY_CREDENTIALS = 'gitea-test-token' REGISTRY_CREDENTIALS = 'gitea-registry-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
} }
stages { stages {
stage('校验参数并检出指定提交') { stage('构建并发布单服务') {
steps { agent { label 'java-builder' }
checkout scm tools { maven 'maven-3.8.8' }
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
script { script {
String service = serviceModules.find { candidate -> String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}") env.JOB_BASE_NAME?.endsWith("-${candidate}")
} }
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()
if (!sourceCommit) { if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
} }
if (!imageTag) { if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
} }
if (!serviceModules.contains(service)) { if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
} }
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { boolean deployBothBridges = service == 'event-bridge-service' &&
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') imageTag?.endsWith('-both')
} if (deployBothBridges) {
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) { imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
error('IMAGE_TAG 不是合法的 Docker 镜像标签') }
} if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
if (!imageRepository || imageRepository.contains('://')) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://') }
} if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) { error('IMAGE_TAG 不是合法的 Docker 镜像标签')
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式') }
} if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
if (!imageRepository.contains('/')) {
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
env.RESOLVED_SERVICE = service env.RESOLVED_SERVICE = service
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 '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
}
}
} }
sh ''' stage('Maven 测试与打包') {
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then steps {
git fetch --no-tags origin script {
fi boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
git checkout --detach "${RESOLVED_SOURCE_COMMIT}" error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" }
''' boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.SKIP_TESTS && coordinatorPinnedCommit) {
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}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
}
script { stage('构建并发布单服务镜像') {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}" steps {
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}" script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
String dockerConfigDir = "${pwd(tmp: true)}/docker-auth"
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
echo "推送共享镜像: ${image}"
withEnv(["DOCKER_CONFIG=${dockerConfigDir}"]) {
sh 'mkdir -p "$DOCKER_CONFIG"'
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
sh "docker push ${image}"
} finally {
sh "docker logout ${registryHost} || true"
sh 'rm -rf "$DOCKER_CONFIG"'
}
}
}
if (params.DEPLOY) {
stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true
}
}
}
} }
} }
} }
stage('Maven 测试与打包') { stage('部署开发或测试环境') {
when {
beforeAgent true
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
}
agent none
steps { steps {
script { script {
String testOption = params.SKIP_TESTS ? '-DskipTests' : '' String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
} String credentialId = "firstleg-${params.ENVIRONMENT}-env"
} String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
post { // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
always { // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml' lock(resource: serviceDeployResource, inversePrecedence: true) {
} lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
} node(targetLabel) {
} deleteDir()
try {
stage('构建并发布单服务镜像') { unstash 'deployment-files'
steps { echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
script { List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}" (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}" ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
String jar = serviceJars[env.RESOLVED_SERVICE] ? ['CN', 'OVERSEAS']
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'" : [params.EVENT_BRIDGE_REGION]
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'" withCredentials([
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
if (params.ENVIRONMENT == 'test') { usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
echo "测试镜像已保存在当前 Docker 节点:${image}" usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
} else { ]) {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0] bridgeRegions.each { bridgeRegion ->
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { if (env.RESOLVED_SERVICE == 'event-bridge-service') {
try { echo "部署事件桥实例: ${bridgeRegion}"
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"' }
sh "docker push ${image}" withEnv([
} finally { 'TARGET_IS_LOCAL=true',
sh "docker logout ${registryHost} || 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)
}
} }
} }
} }
@@ -147,15 +239,44 @@ pipeline {
} }
stage('部署单个服务') { stage('部署单个服务') {
when { expression { params.DEPLOY } } when {
beforeAgent true
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" String credentialId = "firstleg-${params.ENVIRONMENT}-env"
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) { String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' &&
env.NODE_LABELS?.tokenize()?.contains('java-dev')
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
withEnv([
"DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}",
"EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}"
]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
} finally {
sh "docker logout ${registryHost} || true"
}
} }
} }
} }
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
} }
} }
@@ -163,8 +284,5 @@ pipeline {
success { success {
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}" echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
} }
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
} }
} }
+13 -7
View File
@@ -1,29 +1,35 @@
# 柏悦头程平台 # 柏悦头程平台
本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
## 模块 ## 模块
- `auth`:统一登录、令牌签发与刷新。 - `auth`:统一登录、令牌签发与刷新。
- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
- `order`:订单主数据及订单状态编排。 - `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。 - `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。 - `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 - `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。
- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 - `platform-bom`:统一管理仓库内公共依赖版本。
## 本地构建 ## 本地构建
```powershell ```powershell
mvn -DskipTests validate mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test mvn test
``` ```
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`。 国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
@@ -39,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界 ## 仓库边界
详细规则见 `docs/repository-boundary.md`。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
+3 -1
View File
@@ -11,6 +11,7 @@
|---|---| |---|---|
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` | | 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) | | 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` |
| 接收 DTO | `dto/AuditIngestRequest.java` | | 接收 DTO | `dto/AuditIngestRequest.java` |
| 入库编排 | `service/AuditIngestService.java` | | 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
@@ -23,5 +24,6 @@
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。 - `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。 - `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。 - 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。 - 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。 - 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
+9
View File
@@ -27,6 +27,11 @@
<artifactId>baiyue-common-openapi</artifactId> <artifactId>baiyue-common-openapi</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-security</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@@ -36,6 +41,10 @@
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.alibaba.cloud</groupId> <groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
@@ -1,13 +1,18 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
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.scheduling.annotation.EnableScheduling;
/** /**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan("com.baiyue.audit.mapper")
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
public class BaiyueAuditApplication { public class BaiyueAuditApplication {
@@ -8,11 +8,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
@@ -25,8 +29,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/internal/audit/events") @RequestMapping("/internal/audit/events")
public class AuditIngestController { public class AuditIngestController {
private static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
@Resource @Resource
private AuditIngestService service; private AuditIngestService service;
@Value("${baiyue.security.internal-service-token:}")
private String internalServiceToken;
@Operation( @Operation(
summary = "Ingest business audit event / 写入业务审计事件", summary = "Ingest business audit event / 写入业务审计事件",
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。" description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
@@ -35,9 +44,25 @@ public class AuditIngestController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
}) })
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.ACCEPTED) public ResponseEntity<ApiResponse<Void>> ingest(
public ApiResponse<Void> ingest(@Valid @RequestBody AuditIngestRequest event) { @RequestHeader(value = INTERNAL_SERVICE_TOKEN_HEADER, required = false) String providedToken,
@Valid @RequestBody AuditIngestRequest event) {
if (!tokenMatches(providedToken)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.FORBIDDEN,
"Internal service token is invalid", null, null));
}
service.ingest(event); service.ingest(event);
return ApiResponse.success(null); return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(null));
}
private boolean tokenMatches(String providedToken) {
if (providedToken == null || providedToken.isBlank()
|| internalServiceToken == null || internalServiceToken.isBlank()) {
return false;
}
return MessageDigest.isEqual(
internalServiceToken.getBytes(StandardCharsets.UTF_8),
providedToken.getBytes(StandardCharsets.UTF_8));
} }
} }
@@ -0,0 +1,65 @@
package com.baiyue.audit.controller;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.service.AuditLogQueryService;
import com.baiyue.audit.service.AuditPlatformScopeGuard;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
/** 平台端统一操作日志查询入口。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/audit/logs")
@Tag(name = "Platform audit logs / 平台操作日志")
public class PlatformAuditLogController {
@Resource
private AuditLogQueryService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:audit:list')")
@Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。")
public ApiResponse<PageResult<AuditLogResponses.ListItem>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
@RequestParam(required = false) Long accountId,
@RequestParam(required = false) Long operatorUserId,
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String resourceType,
@RequestParam(required = false) String resourceId,
@RequestParam(required = false) Boolean success,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo,
@RequestParam(required = false) String keyword) {
AuditPlatformScopeGuard.requirePlatform(user);
return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType,
resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:audit:list')")
@Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。")
public ApiResponse<AuditLogResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) Long id) {
AuditPlatformScopeGuard.requirePlatform(user);
return ApiResponse.success(service.detail(id));
}
}
@@ -13,6 +13,8 @@ import java.time.Instant;
*/ */
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件") @Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
public record AuditIngestRequest( public record AuditIngestRequest(
@Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021")
@Size(max = 64) String eventId,
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String eventType, @NotBlank @Size(max = 64) String eventType,
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
@@ -0,0 +1,19 @@
package com.baiyue.audit.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */
@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件")
public record AuditLogQuery(
Long accountId,
Long operatorUserId,
String eventType,
String resourceType,
String resourceId,
Boolean success,
Instant occurredFrom,
Instant occurredTo,
String keyword) {
}
@@ -0,0 +1,67 @@
package com.baiyue.audit.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.time.LocalDateTime;
/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */
public final class AuditLogResponses {
private AuditLogResponses() {
}
@Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照")
public record ListItem(
Long id,
String eventId,
String eventType,
String resourceType,
String resourceId,
String description,
Long accountId,
Long operatorTenantId,
Long userId,
String username,
String appCode,
String clientType,
String clientIp,
String requestId,
String traceId,
String serviceName,
Boolean success,
String errorCode,
String errorMessage,
Instant occurredAt,
LocalDateTime createdAt) {
}
@Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照")
public record Detail(
Long id,
String eventId,
String eventType,
String resourceType,
String resourceId,
String description,
String requestId,
String traceId,
Long accountId,
Long operatorTenantId,
Long userId,
String username,
String appCode,
String clientType,
String httpMethod,
String requestUri,
String clientIp,
Long durationMs,
Boolean success,
String errorCode,
String errorMessage,
String beforeData,
String afterData,
String serviceName,
Instant occurredAt,
LocalDateTime createdAt) {
}
}
@@ -23,6 +23,7 @@ public class AuditLogEntity {
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201") @Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
@TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO)
private Long id; private Long id;
private String eventId;
private String eventType; private String eventType;
private String resourceType; private String resourceType;
private String resourceId; private String resourceId;
@@ -0,0 +1,40 @@
package com.baiyue.audit.mq;
import com.baiyue.audit.repository.AuditDeadLetterRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true")
@RocketMQMessageListener(
topic = "${baiyue.audit.rocketmq.dead-letter-topic}",
consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}")
public class AuditDeadLetterConsumer implements RocketMQListener<String> {
@Resource
private ObjectMapper json;
@Resource
private AuditDeadLetterRepository repository;
@Override
public void onMessage(String payload) {
repository.save(eventId(payload), payload);
}
private String eventId(String payload) {
try {
String value = json.readTree(payload).path("eventId").asText();
if (!value.isBlank()) return value;
} catch (Exception ignored) {
// 无法解析的毒消息也必须使用确定性 ID 收入死信表。
}
return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,34 @@
package com.baiyue.audit.mq;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.service.AuditIngestService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true")
@RocketMQMessageListener(
topic = "${baiyue.audit.rocketmq.topic}",
selectorExpression = "AUDIT",
consumerGroup = "${baiyue.audit.rocketmq.consumer-group}")
public class AuditRocketMqConsumer implements RocketMQListener<String> {
@Resource
private ObjectMapper json;
@Resource
private AuditIngestService service;
@Override
public void onMessage(String payload) {
try {
service.ingest(json.readValue(payload, AuditIngestRequest.class));
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("审计消息格式无效", exception);
}
}
}
@@ -0,0 +1,73 @@
package com.baiyue.audit.repository;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 审计 RocketMQ 死信及补偿状态仓储。 */
@Repository
public class AuditDeadLetterRepository {
@Resource
private JdbcTemplate jdbc;
public void save(String eventId, String payload) {
jdbc.update("""
INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at)
VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text)
""", eventId, payload);
}
public List<Long> pendingIds(int limit) {
return jdbc.queryForList("""
SELECT id FROM sys_audit_dead_letter
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
ORDER BY next_retry_at,id LIMIT ?
""", Long.class, limit);
}
public boolean claim(long id, String owner, int leaseSeconds) {
return jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?,
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1
WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
""", owner, leaseSeconds, id) == 1;
}
public DeadRow find(long id) {
List<DeadRow> rows = jdbc.query(
"SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?",
(rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id);
return rows.isEmpty() ? null : rows.get(0);
}
public void processed(long id, String owner) {
jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,last_error=NULL
WHERE id=? AND status='PROCESSING' AND lease_owner=?
""", id, owner);
}
public void failed(long id, String owner, int maxAttempts, String error) {
jdbc.update("""
UPDATE sys_audit_dead_letter
SET status=IF(retry_count>=?,'DEAD','PENDING'),
next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),
lease_owner=NULL,lease_until=NULL,last_error=?
WHERE id=? AND status='PROCESSING' AND lease_owner=?
""", maxAttempts, error, id, owner);
}
public int releaseExpired() {
return jdbc.update("""
UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL
WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
""");
}
public record DeadRow(long id, String eventId, String payload, int retryCount) { }
}
@@ -1,23 +1,29 @@
package com.baiyue.audit.repository; package com.baiyue.audit.repository;
import jakarta.annotation.Resource;
import com.baiyue.audit.dto.AuditIngestRequest; import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.mapper.AuditLogMapper; import com.baiyue.audit.mapper.AuditLogMapper;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.util.StringUtils;
/** /**
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。 * 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
*/ */
@Repository @Repository
public class AuditEventRepository { public class AuditEventRepository {
private final AuditLogMapper mapper; @Resource
private AuditLogMapper mapper;
public AuditEventRepository(AuditLogMapper mapper) { public boolean save(AuditIngestRequest event, String eventId) {
this.mapper = mapper;
}
public void save(AuditIngestRequest event) {
AuditLogEntity entity = new AuditLogEntity(); AuditLogEntity entity = new AuditLogEntity();
entity.setEventId(eventId);
entity.setEventType(event.eventType()); entity.setEventType(event.eventType());
entity.setResourceType(event.resourceType()); entity.setResourceType(event.resourceType());
entity.setResourceId(event.resourceId()); entity.setResourceId(event.resourceId());
@@ -41,7 +47,40 @@ public class AuditEventRepository {
entity.setAfterData(jsonOrNull(event.afterData())); entity.setAfterData(jsonOrNull(event.afterData()));
entity.setOccurredAt(event.occurredAt()); entity.setOccurredAt(event.occurredAt());
entity.setServiceName(event.serviceName()); entity.setServiceName(event.serviceName());
mapper.insert(entity); try {
mapper.insert(entity);
return true;
} catch (DuplicateKeyException ignored) {
return false;
}
}
public PageResult<AuditLogEntity> page(AuditLogQuery query, long page, long size) {
LambdaQueryWrapper<AuditLogEntity> wrapper = new LambdaQueryWrapper<AuditLogEntity>()
.eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId())
.eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId())
.eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType())
.eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType())
.eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId())
.eq(query.success() != null, AuditLogEntity::getSuccess, query.success())
.ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom())
.le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo())
.orderByDesc(AuditLogEntity::getOccurredAt)
.orderByDesc(AuditLogEntity::getId);
if (StringUtils.hasText(query.keyword())) {
String keyword = query.keyword().trim();
wrapper.and(condition -> condition
.like(AuditLogEntity::getDescription, keyword)
.or().like(AuditLogEntity::getUsername, keyword)
.or().like(AuditLogEntity::getResourceId, keyword)
.or().like(AuditLogEntity::getRequestId, keyword)
.or().like(AuditLogEntity::getTraceId, keyword));
}
return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper));
}
public AuditLogEntity findById(Long id) {
return mapper.selectById(id);
} }
private static String jsonOrNull(String value) { private static String jsonOrNull(String value) {
@@ -0,0 +1,47 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditDeadLetterRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */
@Component
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true")
public class AuditDeadLetterCompensator {
@Resource
private AuditDeadLetterRepository repository;
@Resource
private AuditIngestService service;
@Resource
private ObjectMapper json;
@Value("${spring.application.name:audit-service}-${random.uuid}")
private String owner;
@Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}")
private int maxAttempts;
@Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}")
public void compensate() {
repository.releaseExpired();
for (Long id : repository.pendingIds(50)) {
if (!repository.claim(id, owner, 60)) continue;
AuditDeadLetterRepository.DeadRow row = repository.find(id);
if (row == null) continue;
try {
service.ingest(json.readValue(row.payload(), AuditIngestRequest.class));
repository.processed(id, owner);
} catch (Exception exception) {
repository.failed(id, owner, maxAttempts, safe(exception.getMessage()));
}
}
}
private static String safe(String value) {
String message = value == null ? "死信补偿失败" : value;
return message.length() <= 1000 ? message : message.substring(0, 1000);
}
}
@@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditEventRepository; import com.baiyue.audit.repository.AuditEventRepository;
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.util.StringUtils;
import java.util.UUID;
/** /**
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。 * 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
@@ -16,7 +19,8 @@ public class AuditIngestService {
@Transactional @Transactional
public void ingest(AuditIngestRequest event) { public boolean ingest(AuditIngestRequest event) {
repository.save(event); String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString();
return repository.save(event, eventId);
} }
} }
@@ -0,0 +1,48 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.repository.AuditEventRepository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
/** 平台统一操作日志查询服务。 */
@Service
public class AuditLogQueryService {
@Resource
private AuditEventRepository repository;
public PageResult<AuditLogResponses.ListItem> page(AuditLogQuery query, long page, long size) {
PageResult<AuditLogEntity> source = repository.page(query, page, size);
return new PageResult<>(source.records().stream().map(this::toListItem).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public AuditLogResponses.Detail detail(Long id) {
AuditLogEntity entity = repository.findById(id);
if (entity == null) {
throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在");
}
return new AuditLogResponses.Detail(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(),
entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(),
entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(),
entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(),
entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(),
entity.getCreatedAt());
}
private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
}
@@ -0,0 +1,20 @@
package com.baiyue.audit.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */
public final class AuditPlatformScopeGuard {
private AuditPlatformScopeGuard() {
}
public static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId() == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "请先登录");
}
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志");
}
}
}
@@ -26,6 +26,19 @@ spring:
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE:public}
baiyue.audit.enabled: false baiyue.audit.enabled: false
baiyue:
audit:
rocketmq:
consumer-enabled: ${AUDIT_MQ_CONSUMER_ENABLED:false}
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1
dead-letter-consumer-enabled: ${AUDIT_DLQ_CONSUMER_ENABLED:false}
dead-letter-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1"
dead-letter-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-dlq-handler-v1
dead-letter-compensation-enabled: ${AUDIT_DLQ_COMPENSATION_ENABLED:false}
dead-letter-max-attempts: ${AUDIT_DLQ_MAX_ATTEMPTS:5}
rocketmq:
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: logging:
@@ -35,6 +48,6 @@ logging:
rollingpolicy: rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" 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-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
+21 -2
View File
@@ -4,14 +4,15 @@ USE baiyue_audit;
CREATE TABLE IF NOT EXISTS sys_audit_log ( CREATE TABLE IF NOT EXISTS sys_audit_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID', id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
event_id VARCHAR(64) DEFAULT NULL COMMENT '审计事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码', event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型', resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID', resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明', description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪', request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID', trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID', tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0',
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID', operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0',
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID', user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照', username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端', app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
@@ -29,8 +30,26 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间', occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_audit_event_id (event_id),
KEY idx_audit_tenant_time (tenant_id, occurred_at), KEY idx_audit_tenant_time (tenant_id, occurred_at),
KEY idx_audit_event_time (event_type, occurred_at), KEY idx_audit_event_time (event_type, occurred_at),
KEY idx_audit_user_time (user_id, occurred_at), KEY idx_audit_user_time (user_id, occurred_at),
KEY idx_audit_request (request_id) KEY idx_audit_request (request_id)
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; ) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
@@ -0,0 +1,39 @@
USE baiyue_audit;
DROP PROCEDURE IF EXISTS add_audit_event_id;
DELIMITER $$
CREATE PROCEDURE add_audit_event_id()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id'
) THEN
ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id'
) THEN
ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id (event_id);
END IF;
END$$
DELIMITER ;
CALL add_audit_event_id();
DROP PROCEDURE IF EXISTS add_audit_event_id;
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_audit_dead_event (event_id),
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
@@ -0,0 +1,17 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueAuditApplicationContractTest {
@Test
void importsSharedMybatisPaginationConfiguration() {
Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class);
assertThat(imported).isNotNull();
assertThat(imported.value()).contains(MybatisPlusConfiguration.class);
}
}
@@ -0,0 +1,74 @@
package com.baiyue.audit.controller;
import com.baiyue.audit.service.AuditIngestService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringJUnitConfig(AuditIngestController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
class AuditIngestControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@MockBean
private AuditIngestService service;
@Resource
private AuditIngestController controller;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void rejectsInvalidInternalToken() throws Exception {
mockMvc.perform(post("/internal/audit/events")
.header("X-Internal-Service-Token", "wrong-token")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("A0403"));
verify(service, never()).ingest(any());
}
@Test
void acceptsEventWithInternalToken() throws Exception {
mockMvc.perform(post("/internal/audit/events")
.header("X-Internal-Service-Token", TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.code").value("200"));
verify(service).ingest(any());
}
private String validRequest() {
return """
{
"eventType":"ROLE_UPDATE",
"resourceType":"ROLE",
"durationMs":0,
"success":true,
"occurredAt":"2026-08-05T00:00:00Z"
}
""";
}
}
@@ -0,0 +1,92 @@
package com.baiyue.audit.controller;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.service.AuditLogQueryService;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(PlatformAuditLogController.class)
class PlatformAuditLogControllerContractTest {
@MockBean
private AuditLogQueryService service;
@Resource
private PlatformAuditLogController controller;
@Test
void platformCanQueryLogList() {
when(service.page(any(AuditLogQuery.class), org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(20L)))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(user("PLATFORM"), 1, 20, 10001L, 7L, "ROLE_UPDATE", "ROLE", "9",
true, null, null, "管理员");
assertThat(response.successful()).isTrue();
verify(service).page(new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
null, null, "管理员"), 1, 20);
}
@Test
void merchantCannotReadPlatformLogs() {
assertThatThrownBy(() -> controller.page(user("MERCHANT"), 1, 20, null, null, null, null,
null, null, null, null, null)).isInstanceOf(BizException.class)
.hasMessage("仅平台端可查看操作日志");
verifyNoInteractions(service);
}
@Test
void platformApplicationWithCustomerTenantCannotReadLogs() {
AuthenticatedUser malformed = new AuthenticatedUser(7L, 10001L, 10001L, 1L,
"异常平台账号", "PLATFORM", "WEB", Set.of("platform:audit:list"));
assertThatThrownBy(() -> controller.page(malformed, 1, 20, null, null, null, null,
null, null, null, null, null)).isInstanceOf(BizException.class)
.hasMessage("仅平台端可查看操作日志");
verifyNoInteractions(service);
}
@Test
void platformCanReadLogDetail() {
AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null,
null, null, null, null, 7L, "管理员", "PLATFORM", "WEB", null, null, null, 1L,
true, null, null, "{}", "{}", "iam-service", null, null);
when(service.detail(8L)).thenReturn(detail);
assertThat(controller.detail(user("PLATFORM"), 8L).data()).isEqualTo(detail);
verify(service).detail(8L);
}
@Test
void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException {
String expected = "hasAuthority('platform:audit:list')";
assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class,
long.class, long.class, Long.class, Long.class, String.class, String.class, String.class,
Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
}
}
@@ -0,0 +1,107 @@
package com.baiyue.audit.mq;
import com.baiyue.audit.repository.AuditDeadLetterRepository;
import com.baiyue.audit.service.AuditDeadLetterCompensator;
import com.baiyue.audit.service.AuditIngestService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {
AuditMqReliabilityTest.Config.class,
AuditRocketMqConsumer.class,
AuditDeadLetterConsumer.class,
AuditDeadLetterCompensator.class
})
@TestPropertySource(properties = {
"baiyue.audit.rocketmq.consumer-enabled=true",
"baiyue.audit.rocketmq.dead-letter-consumer-enabled=true",
"baiyue.audit.rocketmq.dead-letter-compensation-enabled=true",
"baiyue.audit.rocketmq.topic=test-audit",
"baiyue.audit.rocketmq.consumer-group=test-audit-consumer",
"baiyue.audit.rocketmq.dead-letter-topic=%DLQ%test-audit-consumer",
"baiyue.audit.rocketmq.dead-letter-group=test-audit-dlq",
"spring.application.name=audit-service-test"
})
class AuditMqReliabilityTest {
private static final String PAYLOAD = """
{"eventId":"event-1","eventType":"ROLE_UPDATE","resourceType":"ROLE",
"durationMs":10,"success":true,"occurredAt":"2026-08-05T00:00:00Z"}
""";
@MockBean
private AuditIngestService service;
@MockBean
private AuditDeadLetterRepository repository;
@Resource
private AuditRocketMqConsumer consumer;
@Resource
private AuditDeadLetterConsumer deadLetterConsumer;
@Resource
private AuditDeadLetterCompensator compensator;
@Test
void consumesAuditEvent() {
consumer.onMessage(PAYLOAD);
verify(service).ingest(any());
}
@Test
void rejectsInvalidPayload() {
assertThatThrownBy(() -> consumer.onMessage("not-json"))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void storesDeadLetter() {
deadLetterConsumer.onMessage(PAYLOAD);
verify(repository).save("event-1", PAYLOAD);
}
@Test
void compensatesDeadLetter() {
when(repository.pendingIds(50)).thenReturn(java.util.List.of(7L));
when(repository.claim(eq(7L), anyString(), eq(60))).thenReturn(true);
when(repository.find(7L)).thenReturn(
new AuditDeadLetterRepository.DeadRow(7L, "event-1", PAYLOAD, 1));
compensator.compensate();
verify(service).ingest(any());
verify(repository).processed(eq(7L), anyString());
}
@Test
void retriesFailedCompensation() {
when(repository.pendingIds(50)).thenReturn(java.util.List.of(8L));
when(repository.claim(eq(8L), anyString(), eq(60))).thenReturn(true);
when(repository.find(8L)).thenReturn(
new AuditDeadLetterRepository.DeadRow(8L, "event-2", "not-json", 1));
compensator.compensate();
verify(repository).failed(eq(8L), anyString(), eq(5), anyString());
}
@Configuration
static class Config {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper().findAndRegisterModules();
}
}
}
@@ -0,0 +1,39 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditEventRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AuditIngestService.class)
class AuditIngestIdempotencyTest {
@MockBean
private AuditEventRepository repository;
@Resource
private AuditIngestService service;
@Test
void keepsStableEventIdForMqRedelivery() {
AuditIngestRequest request = request("audit-1");
when(repository.save(request, "audit-1")).thenReturn(false);
assertThat(service.ingest(request)).isFalse();
verify(repository).save(request, "audit-1");
}
private static AuditIngestRequest request(String eventId) {
return new AuditIngestRequest(eventId, "ROLE_UPDATE", "ROLE", "1", "修改角色",
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", "PUT",
"/roles/1", "127.0.0.1", 10, true, null, null, null, null,
Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
}
}
@@ -0,0 +1,103 @@
package com.baiyue.audit.service;
import com.baiyue.audit.dto.AuditLogQuery;
import com.baiyue.audit.dto.AuditLogResponses;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.repository.AuditEventRepository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AuditLogQueryService.class)
class AuditLogQueryServiceTest {
@MockBean
private AuditEventRepository repository;
@Resource
private AuditLogQueryService service;
@Test
void pageDoesNotExposeSnapshots() {
AuditLogQuery query = new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
null, null, "管理员");
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 1, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(8L);
assertThat(item.accountId()).isEqualTo(10001L);
assertThat(item.eventType()).isEqualTo("ROLE_UPDATE");
});
assertThat(AuditLogResponses.ListItem.class.getRecordComponents())
.extracting(component -> component.getName())
.doesNotContain("beforeData", "afterData");
}
@Test
void pagePreservesTotalAndPagesForCustomerLogPagination() {
AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null,
null, null, null);
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test
void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L));
AuditLogResponses.Detail detail = service.detail(8L);
assertThat(detail.beforeData()).isEqualTo("{\"name\":\"旧角色\"}");
assertThat(detail.afterData()).isEqualTo("{\"name\":\"新角色\"}");
}
@Test
void missingLogIsReportedAsNotFound() {
when(repository.findById(8L)).thenReturn(null);
assertThatThrownBy(() -> service.detail(8L)).isInstanceOf(BizException.class)
.hasMessage("操作日志不存在");
}
private static AuditLogEntity log(Long id) {
AuditLogEntity entity = new AuditLogEntity();
entity.setId(id);
entity.setEventId("audit-8");
entity.setEventType("ROLE_UPDATE");
entity.setResourceType("ROLE");
entity.setResourceId("9");
entity.setDescription("管理员修改角色权限");
entity.setTenantId(10001L);
entity.setOperatorTenantId(0L);
entity.setUserId(7L);
entity.setUsername("管理员");
entity.setAppCode("PLATFORM");
entity.setClientType("WEB");
entity.setRequestId("request-8");
entity.setTraceId("trace-8");
entity.setSuccess(true);
entity.setBeforeData("{\"name\":\"旧角色\"}");
entity.setAfterData("{\"name\":\"新角色\"}");
entity.setServiceName("iam-service");
entity.setOccurredAt(Instant.parse("2026-08-05T10:00:00Z"));
entity.setCreatedAt(LocalDateTime.of(2026, 8, 5, 18, 0));
return entity;
}
}
+27 -24
View File
@@ -1,32 +1,35 @@
# Auth Service 模块指引 # Auth Service 模块指引
## 职责边界 ## 职责与边界
- 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 租户、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 快速索引 ## 模块入口
| 功能 | 入口 | | 目标 | 入口 |
|---|---| |---|---|
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` | | 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 | | 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
| 商户注册协作 | `service/MerchantRegistrationService.java` | | 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` | | 注册协作 | `service/MerchantRegistrationService.java` |
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、租户、成员与应用端 | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` | | DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` | | SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
| 测试/验证 | `src/test/java/com/baiyue/auth/`;`mvn -pl auth -am test` |
## 修改与验证 ## 修改约束
- 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。 - 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
- 平台统一用户管理跨租户移动成员时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 默认租户、成员和应用端;修改该契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。 ## 全量验收导航
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
+76
View File
@@ -0,0 +1,76 @@
# 国内与海外 JWT/JWKS 冻结契约
## 双轨边界
### 国内现有链路
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
### 海外尾程链路
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
## 海外签发契约
- `issuer`:`auth-service`
- `audience`:`lastleg-api`
- 算法白名单:仅 `RS256`
- Access Token 默认有效期:15 分钟
- 兑换接口:`POST /api/auth/overseas/token`
- JWKS 路径:`/api/auth/.well-known/jwks.json`
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
海外 Token 的 JSON 声明名称和类型固定如下:
| 声明 | JSON 类型 | 说明 |
|---|---|---|
| `sub` | string | 用户 ID 的字符串形式 |
| `jti` | string | Token 唯一标识 |
| `iat` | number | 签发时间 |
| `exp` | number | 过期时间 |
| `aud` | string array | 固定包含 `lastleg-api` |
| `userId` | number | 认证用户 ID |
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
| `roles` | string array | 当前角色编码快照 |
| `authorities` | string array | 当前权限编码快照 |
| `permissionVersion` | number | IAM 单调权限版本 |
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
## 海外配置
```yaml
baiyue:
security:
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
```
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
## 密钥轮换
- JWK Set 只允许 RSA 密钥。
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
## IAM 安全事件
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
+1
View File
@@ -6,6 +6,7 @@
<artifactId>auth-service</artifactId> <artifactId>auth-service</artifactId>
<dependencies> <dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
@@ -1,5 +1,8 @@
package com.baiyue.auth.client; package com.baiyue.auth.client;
import jakarta.annotation.Resource;
import jakarta.annotation.PostConstruct;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
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;
@@ -7,21 +10,23 @@ import feign.FeignException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Objects;
/** /**
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。
*/ */
@Component @Component
public class AuthorizationLoader { public class AuthorizationLoader {
private final IamAuthorizationClient client; @Resource
private final String internalToken; private IamAuthorizationClient client;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public AuthorizationLoader(IamAuthorizationClient client, @PostConstruct
@Value("${baiyue.security.internal-service-token}") String internalToken) { void validateConfiguration() {
if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
} }
this.client = client;
this.internalToken = internalToken;
} }
public AuthorizationSnapshot load(AuthAccount account) { public AuthorizationSnapshot load(AuthAccount account) {
@@ -39,10 +44,17 @@ public class AuthorizationLoader {
} }
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
} }
if (response.data() == null) { AuthorizationSnapshot snapshot = response.data();
if (snapshot == null) {
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限"); throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
} }
return response.data(); if (!Objects.equals(account.getId(), snapshot.getUserId())
|| !Objects.equals(account.getDefaultTenantId(), snapshot.getTenantId())
|| !Objects.equals(account.getDefaultMembershipId(), snapshot.getMembershipId())
|| !Objects.equals(account.getDefaultAppCode(), snapshot.getAppCode())) {
throw new BizException(ResultCode.FORBIDDEN, "IAM 授权快照身份与账号归属不一致");
}
return snapshot;
} catch (FeignException exception) { } catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
} catch (BizException exception) { } catch (BizException exception) {
@@ -20,4 +20,13 @@ public class AuthorizationSnapshot {
private String clientType; private String clientType;
private Set<String> roles; private Set<String> roles;
private Set<String> permissions; private Set<String> permissions;
private Long permissionVersion;
private Long overseasWarehouseId;
public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, null);
}
} }
@@ -1,6 +1,8 @@
package com.baiyue.auth.client; package com.baiyue.auth.client;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -14,9 +16,15 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request); @RequestBody OnboardingRequest request);
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@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 legalPhone, 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,
String dimensionInitializationStatus) { }
} }
@@ -1,6 +1,5 @@
package com.baiyue.auth.config; package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.baiyue.common.security.JwtAuthenticationFilter; import com.baiyue.common.security.JwtAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
/** /**
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。 * 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
@@ -25,15 +21,21 @@ import java.nio.charset.StandardCharsets;
@Configuration @Configuration
@EnableConfigurationProperties(AuthSecurityProperties.class) @EnableConfigurationProperties(AuthSecurityProperties.class)
public class AuthSecurityConfig { public class AuthSecurityConfig {
@Bean static final String[] PUBLIC_AUTH_ENDPOINTS = {
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } "/api/auth/register",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/api/auth/impersonations/exchange",
"/api/auth/.well-known/jwks.json",
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
};
@Bean @Bean
JwtEncoder jwtEncoder(AuthSecurityProperties properties) { PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
@Bean @Bean
SecurityFilterChain authSecurityFilterChain(HttpSecurity http, SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
@@ -47,8 +49,7 @@ public class AuthSecurityConfig {
.exceptionHandling(errors -> errors .exceptionHandling(errors -> errors
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout", .requestMatchers(PUBLIC_AUTH_ENDPOINTS).permitAll()
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/internal/auth/**").permitAll() .requestMatchers("/internal/auth/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/ */
@ConfigurationProperties(prefix = "baiyue.security") @ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer, public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl, Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) { int maxLoginFailures, Duration lockDuration) {
} }
@@ -0,0 +1,22 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import java.nio.charset.StandardCharsets;
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
@Configuration(proxyBeanMethods = false)
public class DomesticJwtConfiguration {
@Bean("jwtEncoder")
@Primary
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
}
@@ -0,0 +1,37 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OverseasJwtProperties.class)
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasJwtConfiguration {
@Bean("overseasJwtEncoder")
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
try {
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
if (current == null || !current.isPrivate()) {
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
throw new IllegalStateException("海外非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,10 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.dto.MerchantRegisterResponse; import com.baiyue.auth.dto.MerchantRegisterResponse;
import com.baiyue.auth.service.MerchantRegistrationService; import com.baiyue.auth.service.MerchantRegistrationService;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.security.SecurityRequirements;
@@ -18,7 +20,11 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
/** /**
@@ -34,20 +40,44 @@ public class AuthController {
private MerchantRegistrationService registrationService; private MerchantRegistrationService registrationService;
@Resource @Resource
private AccountManagementService accountManagementService; private AccountManagementService accountManagementService;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
@AuthenticationPrincipal AuthenticatedUser user) {
OverseasTokenService service = overseasTokens.getIfAvailable();
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
return ApiResponse.success(service.exchange(user.userId()));
}
@PutMapping("/password") @PutMapping("/password")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前用户密码") @Operation(summary = "修改当前用户密码")
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ChangePasswordRequest request) { @Valid @RequestBody ChangePasswordRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
accountManagementService.changePassword(user.userId(), request); accountManagementService.changePassword(user.userId(), request);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@PutMapping("/password/forced")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "完成强制改密")
public ApiResponse<Void> forcedChangePassword(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ForcedPasswordChangeRequest request) {
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
if (!user.passwordChangeRequired()) throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
accountManagementService.forcedChangePassword(user.userId(), request);
return ApiResponse.success(null);
}
@SecurityRequirements @SecurityRequirements
@PostMapping("/register") @PostMapping("/register")
@Operation(summary = "Merchant registration / 客户注册", @Operation(summary = "Merchant registration / 客户注册",
description = "Create a login account and an enterprise profile awaiting one business-license upload.") description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are 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));
} }
@@ -82,4 +112,11 @@ public class AuthController {
authService.logout(request.refreshToken()); authService.logout(request.refreshToken());
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@SecurityRequirements
@PostMapping("/impersonations/exchange")
public ApiResponse<AuthService.ImpersonationTokenResponse> exchangeImpersonation(
@Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) {
return ApiResponse.success(authService.exchangeImpersonationTicket(request.code()));
}
} }
@@ -2,12 +2,17 @@ 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.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 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.*;
@@ -17,25 +22,39 @@ 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")
public class InternalAccountController { public class InternalAccountController {
@Resource @Resource
private AccountManagementService service; private AccountManagementService service;
@Resource
private AuthService authService;
@Resource
private OverseasSessionValidationService overseasSessionValidationService;
private final String configuredToken; @Value("${baiyue.security.internal-service-token}")
private String configuredToken;
private byte[] tokenBytes; private byte[] tokenBytes;
public InternalAccountController( @PostConstruct
@Value("${baiyue.security.internal-service-token}") String configuredToken) { void init() {
this.configuredToken = configuredToken; byte[] configured = configuredToken == null
? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8);
if (configured.length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
tokenBytes = configured;
} }
@PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping @PostMapping
public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody CreateAccountRequest request) { @Valid @RequestBody CreateAccountRequest request) {
verify(token); return ApiResponse.success(service.create(request)); verify(token); return ApiResponse.success(service.create(request));
} }
@GetMapping("/availability")
public ApiResponse<Boolean> availability(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestParam String username) {
verify(token); return ApiResponse.success(service.usernameAvailable(username));
}
@GetMapping("/{id}") @GetMapping("/{id}")
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id) { @PathVariable Long id) {
@@ -46,6 +65,21 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) { @PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
verify(token); service.updateStatus(id, request); return ApiResponse.success(null); verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
} }
@PostMapping("/sessions/revoke")
public ApiResponse<Void> revokeSessions(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody RevokeAccountSessionsRequest request) {
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
}
@PostMapping("/overseas-session/validate")
@Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明",
description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.")
public ApiResponse<OverseasSessionValidationDtos.Response> validateOverseasSession(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody OverseasSessionValidationDtos.Request request) {
verify(token);
return ApiResponse.success(overseasSessionValidationService.validate(request));
}
@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) {
@@ -54,7 +88,20 @@ public class InternalAccountController {
@PutMapping("/{id}/password") @PutMapping("/{id}/password")
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) { @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
verify(token); service.resetPassword(id, request); return ApiResponse.success(null); verify(token); service.resetPassword(id, request, request.forceChange()); return ApiResponse.success(null);
}
@PostMapping("/impersonation-tickets")
public ApiResponse<ImpersonationTicketResponse> impersonationTicket(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@Valid @RequestBody ImpersonationTicketRequest request) {
verify(token); return ApiResponse.success(authService.createImpersonationTicket(request));
}
@PutMapping("/tenant/{tenantId}/archive")
public ApiResponse<Void> archiveTenant(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long tenantId) {
verify(token); service.archiveTenant(tenantId); return ApiResponse.success(null);
} }
@PutMapping("/{id}/scope") @PutMapping("/{id}/scope")
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@@ -0,0 +1,31 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.jwk.JWKSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
}
}
@@ -2,10 +2,12 @@ package com.baiyue.auth.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
/** 账号安全及内部账号管理契约。 */ /** 账号安全及内部账号管理契约。 */
public final class AccountManagementDtos { public final class AccountManagementDtos {
@@ -14,6 +16,10 @@ public final class AccountManagementDtos {
public record ChangePasswordRequest(@NotBlank String oldPassword, public record ChangePasswordRequest(@NotBlank String oldPassword,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record ForcedPasswordChangeRequest(
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId, public record CreateAccountRequest(@NotNull Long accountId,
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username, @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Size(min = 8, max = 64) @NotBlank @Size(min = 8, max = 64)
@@ -31,8 +37,23 @@ public final class AccountManagementDtos {
} }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { } public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { } public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest(
@NotEmpty @Size(max = 500) Set<@NotNull Long> accountIds) {
public RevokeAccountSessionsRequest {
accountIds = Set.copyOf(accountIds);
}
}
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { } @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword,
Boolean forceChangePassword) {
public boolean forceChange() { return Boolean.TRUE.equals(forceChangePassword); }
}
public record ResetPasswordOptions(boolean forceChangePassword) { }
public record ImpersonationTicketRequest(@NotNull Long targetAuthUserId, @NotNull Long tenantId,
@NotNull Long membershipId, @NotNull Long actorUserId,
@NotNull Long actorMembershipId, @NotBlank String reason) { }
public record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId, public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { } @NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
public record AccountSummary(Long accountId, String username, String displayName, String status, public record AccountSummary(Long accountId, String username, String displayName, String status,
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
Long tenantId, Long tenantId,
Long membershipId, Long membershipId,
String tenantStatus, String tenantStatus,
String dimensionInitializationStatus,
String nextStep String nextStep
) { } ) { }
@@ -0,0 +1,29 @@
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
}
}
@@ -11,6 +11,10 @@ public record TokenResponse(
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
@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 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn
) { ) {
} }
@@ -24,6 +24,7 @@ public class AuthAccount {
private Integer failedLoginCount; private Integer failedLoginCount;
private LocalDateTime lockedUntil; private LocalDateTime lockedUntil;
private Integer tokenVersion; private Integer tokenVersion;
private Boolean passwordChangeRequired;
private Long defaultTenantId; private Long defaultTenantId;
private Long operatorTenantId; private Long operatorTenantId;
private Long defaultMembershipId; private Long defaultMembershipId;
@@ -1,20 +1,20 @@
package com.baiyue.auth.repository; package com.baiyue.auth.repository;
import jakarta.annotation.Resource;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.mapper.AuthAccountMapper; import com.baiyue.auth.mapper.AuthAccountMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
/** /**
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。 * 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
*/ */
@Repository @Repository
public class AuthAccountRepository { public class AuthAccountRepository {
private final AuthAccountMapper mapper; @Resource
private AuthAccountMapper mapper;
public AuthAccountRepository(AuthAccountMapper mapper) {
this.mapper = mapper;
}
public AuthAccount findByUsername(String username) { public AuthAccount findByUsername(String username) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>() return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
@@ -33,6 +33,12 @@ public class AuthAccountRepository {
return mapper.selectById(id); return mapper.selectById(id);
} }
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getDefaultTenantId, tenantId)
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
}
public void save(AuthAccount account) { public void save(AuthAccount account) {
mapper.updateById(account); mapper.updateById(account);
} }
@@ -1,5 +1,7 @@
package com.baiyue.auth.repository; package com.baiyue.auth.repository;
import jakarta.annotation.Resource;
import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.entity.AuthRefreshToken;
import com.baiyue.auth.mapper.AuthRefreshTokenMapper; import com.baiyue.auth.mapper.AuthRefreshTokenMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository;
*/ */
@Repository @Repository
public class AuthRefreshTokenRepository { public class AuthRefreshTokenRepository {
private final AuthRefreshTokenMapper mapper; @Resource
private AuthRefreshTokenMapper mapper;
public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) {
this.mapper = mapper;
}
public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) { public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) {
String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
@@ -13,6 +13,7 @@ import com.baiyue.common.audit.BusinessAudit;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */ /** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
@Service @Service
@@ -21,17 +22,38 @@ public class AccountManagementService {
@Resource private PasswordEncoder passwordEncoder; @Resource private PasswordEncoder passwordEncoder;
@Resource private TokenSessionStore tokenSessionStore; @Resource private TokenSessionStore tokenSessionStore;
public boolean usernameAvailable(String username) {
return username != null && !username.isBlank() && !repository.existsByUsername(username.trim());
}
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
public void changePassword(Long accountId, ChangePasswordRequest request) { public void changePassword(Long accountId, ChangePasswordRequest request) {
AuthAccount account = required(accountId); AuthAccount account = required(accountId);
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
}
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) { if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误"); throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
} }
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) { if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同"); throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
} }
setPasswordAndRevoke(account, request.newPassword()); setPasswordAndRevoke(account, request.newPassword(), false);
}
@Transactional
@BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "完成管理员重置后的强制改密", captureAfter = false)
public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) {
AuthAccount account = required(accountId);
if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
}
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同");
}
setPasswordAndRevoke(account, request.newPassword(), false);
} }
@Transactional @Transactional
@@ -77,6 +99,14 @@ public class AccountManagementService {
bumpVersion(account); bumpVersion(account);
} }
@Transactional
@BusinessAudit(event = "ACCOUNT_SESSIONS_REVOKE", resourceType = "ACCOUNT_BATCH",
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
captureBefore = false, captureAfter = false)
public void revokeSessions(Set<Long> accountIds) {
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
}
@Transactional @Transactional
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", @BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "同步账号显示名称", captureAfter = false) description = "同步账号显示名称", captureAfter = false)
@@ -89,8 +119,21 @@ public class AccountManagementService {
@Transactional @Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false) @BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request) { public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
setPasswordAndRevoke(required(accountId), request.newPassword()); setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId",
description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false)
public void archiveTenant(Long tenantId) {
for (AuthAccount account : repository.findMerchantByTenant(tenantId)) {
account.setAccountStatus("DISABLED");
account.setPasswordChangeRequired(false);
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
repository.save(account);
tokenSessionStore.revokeAll(account.getId());
}
} }
@Transactional @Transactional
@@ -121,10 +164,11 @@ public class AccountManagementService {
public AccountSummary summary(Long accountId) { return summary(required(accountId)); } public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
private void setPasswordAndRevoke(AuthAccount account, String password) { private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) {
account.setPasswordHash(passwordEncoder.encode(password)); account.setPasswordHash(passwordEncoder.encode(password));
account.setFailedLoginCount(0); account.setFailedLoginCount(0);
account.setLockedUntil(null); account.setLockedUntil(null);
account.setPasswordChangeRequired(forceChangePassword);
bumpVersion(account); bumpVersion(account);
} }
@@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository; import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.auth.dto.LoginRequest; import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.dto.TokenResponse; import com.baiyue.auth.dto.TokenResponse;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
@@ -21,15 +23,18 @@ 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 java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.UUID; import java.util.UUID;
import java.time.Duration;
import java.util.List;
/** /**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
*/ */
@Service @Service
public class AuthService { public class AuthService {
@@ -45,6 +50,8 @@ public class AuthService {
private AuthorizationLoader authorizationLoader; private AuthorizationLoader authorizationLoader;
@Resource @Resource
private TokenSessionStore tokenSessionStore; private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private final SecureRandom secureRandom = new SecureRandom(); private final SecureRandom secureRandom = new SecureRandom();
@@ -104,30 +111,103 @@ public class AuthService {
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());
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString()) .subject(account.getId().toString())
.claim("user_id", account.getId()) .claim("user_id", account.getId())
.claim("username", account.getUsername()) .claim("username", account.getUsername())
.claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId()) .claim("tenant_id", account.getDefaultTenantId())
.claim("operator_tenant_id", account.getOperatorTenantId()) .claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()) .claim("membership_id", account.getDefaultMembershipId())
.claim("app_code", account.getDefaultAppCode()) .claim("app_code", account.getDefaultAppCode())
.claim("client_type", authorization.getClientType()) .claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("permissions", authorization.getPermissions())
.claim("token_version", account.getTokenVersion()) .claim("token_version", account.getTokenVersion())
.build(); .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion());
if (authorization.getOverseasWarehouseId() != null) {
claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId());
}
JwtClaimsSet claims = claimsBuilder.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); signingHeader(), claims)).getTokenValue();
byte[] random = new byte[48]; byte[] random = new byte[48];
secureRandom.nextBytes(random); secureRandom.nextBytes(random);
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), rawRefresh, tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl()); properties.accessTokenTtl(), properties.refreshTokenTtl());
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(),
overseas == null ? 0L : overseas.expiresIn());
}
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "IMPERSONATION",
resourceId = "#request.tenantId", description = "创建客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTicketResponse createImpersonationTicket(ImpersonationTicketRequest request) {
AuthAccount account = accountRepository.findById(request.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|| !"MERCHANT".equals(account.getDefaultAppCode())
|| !request.tenantId().equals(account.getDefaultTenantId())
|| !request.membershipId().equals(account.getDefaultMembershipId())) {
throw new BizException(ResultCode.CONFLICT, "目标客户管理员账号不可代登录");
}
byte[] bytes = new byte[48];
secureRandom.nextBytes(bytes);
String code = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
String ticketId = UUID.randomUUID().toString();
tokenSessionStore.storeImpersonationTicket(code,
new TokenSessionStore.ImpersonationTicket(ticketId, account.getId(), request.tenantId(),
request.membershipId(), request.actorUserId(), request.actorMembershipId(), request.reason()),
Duration.ofSeconds(60));
return new ImpersonationTicketResponse(code, ticketId, 60);
}
@Transactional
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_EXCHANGE", resourceType = "IMPERSONATION",
resourceId = "'IMPERSONATION_EXCHANGE'", description = "兑换客户代登录授权码", captureBefore = false, captureAfter = false)
public ImpersonationTokenResponse exchangeImpersonationTicket(String code) {
TokenSessionStore.ImpersonationTicket ticket = tokenSessionStore.consumeImpersonationTicket(code);
if (ticket == null) throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
AuthAccount account = accountRepository.findById(ticket.targetAuthUserId());
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
throw new BizException(ResultCode.UNAUTHORIZED, "目标客户账号不可用");
AuthorizationSnapshot authorization = authorizationLoader.load(account);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername()).claim("display_name", account.getDisplayName() == null ? "" : account.getDisplayName())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT")
.claim("permissionVersion", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
}
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
} }
private static BizException invalidCredentials() { private static BizException invalidCredentials() {
@@ -11,13 +11,21 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException; import feign.FeignException;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
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.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicBoolean;
@Service @Service
public class MerchantRegistrationService { public class MerchantRegistrationService {
private static final Logger LOGGER = LoggerFactory.getLogger(MerchantRegistrationService.class);
@Resource private AuthAccountRepository accountRepository; @Resource private AuthAccountRepository accountRepository;
@Resource private PasswordEncoder passwordEncoder; @Resource private PasswordEncoder passwordEncoder;
@Resource private IamOnboardingClient onboardingClient; @Resource private IamOnboardingClient onboardingClient;
@@ -31,6 +39,9 @@ public class MerchantRegistrationService {
throw new BizException(ResultCode.CONFLICT, "Username already exists"); throw new BizException(ResultCode.CONFLICT, "Username already exists");
} }
long accountId = IdWorker.getId(); long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
AtomicBoolean compensationCompleted = new AtomicBoolean(false);
registerRollbackCompensation(accountId, compensationCompleted);
IamOnboardingClient.OnboardingResponse onboarding; IamOnboardingClient.OnboardingResponse onboarding;
try { try {
var response = onboardingClient.register(internalToken, var response = onboardingClient.register(internalToken,
@@ -51,17 +62,26 @@ public class MerchantRegistrationService {
} }
onboarding = response.data(); onboarding = response.data();
} catch (FeignException exception) { } catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable"); BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
"IAM registration service is unavailable");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
} catch (BizException exception) { } catch (BizException exception) {
compensateOnboarding(accountId, exception, compensationCompleted);
throw exception; throw exception;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable"); BizException failure = new BizException(ResultCode.SERVICE_UNAVAILABLE,
"IAM registration service is unavailable");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
} }
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(accountId); account.setId(accountId);
account.setUsername(request.username()); account.setUsername(request.username());
account.setPasswordHash(passwordEncoder.encode(request.password())); account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName()); account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE"); account.setAccountStatus("ACTIVE");
account.setFailedLoginCount(0); account.setFailedLoginCount(0);
@@ -71,8 +91,62 @@ public class MerchantRegistrationService {
account.setDefaultMembershipId(onboarding.membershipId()); account.setDefaultMembershipId(onboarding.membershipId());
account.setDefaultAppCode("MERCHANT"); account.setDefaultAppCode("MERCHANT");
account.setDeleted(0); account.setDeleted(0);
accountRepository.insert(account); try {
accountRepository.insert(account);
} catch (DuplicateKeyException exception) {
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
} catch (RuntimeException exception) {
compensateOnboarding(accountId, exception, compensationCompleted);
throw exception;
}
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE"); onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
}
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_COMMITTED || compensationCompleted.get()) {
return;
}
IllegalStateException rollbackFailure = new IllegalStateException(
"Auth transaction rolled back after IAM onboarding");
if (!compensateOnboarding(accountId, rollbackFailure, compensationCompleted)) {
LOGGER.error("IAM registration compensation failed after Auth rollback, authUserId={}",
accountId, rollbackFailure);
}
}
});
}
private boolean compensateOnboarding(long accountId, RuntimeException registrationFailure,
AtomicBoolean compensationCompleted) {
if (compensationCompleted.get()) {
return true;
}
try {
var response = onboardingClient.compensate(internalToken, accountId);
if (response != null && response.successful()) {
compensationCompleted.set(true);
return true;
}
{
String code = response == null ? "empty-response" : response.code();
String message = response == null ? "IAM compensation returned no response" : response.message();
registrationFailure.addSuppressed(new IllegalStateException(
"IAM registration compensation failed: code=" + code + ", message=" + message));
}
} catch (RuntimeException compensationFailure) {
registrationFailure.addSuppressed(compensationFailure);
}
return false;
} }
} }
@@ -0,0 +1,38 @@
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();
}
}
@@ -0,0 +1,82 @@
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) { }
}
@@ -1,10 +1,14 @@
package com.baiyue.auth.service; package com.baiyue.auth.service;
import jakarta.annotation.Resource;
import jakarta.annotation.PostConstruct;
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 com.baiyue.common.security.RedisTokenKeys; import com.baiyue.common.security.RedisTokenKeys;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
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;
@@ -25,24 +29,27 @@ import java.util.List;
*/ */
@Component @Component
public class TokenSessionStore { public class TokenSessionStore {
private final StringRedisTemplate redisTemplate; @Autowired
private final ObjectMapper objectMapper; private StringRedisTemplate redisTemplate;
private final String keyPrefix; @Resource
private ObjectMapper objectMapper;
@Value("${baiyue.security.token-key-prefix:baiyue:auth}")
private String keyPrefix;
public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, @PostConstruct
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { void normalizeKeyPrefix() {
this.redisTemplate = redisTemplate; keyPrefix = RedisTokenKeys.normalize(keyPrefix);
this.objectMapper = objectMapper;
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
} }
public void store(String jwtId, Long userId, String rawRefreshToken, public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
Duration accessTtl, Duration refreshTtl) { String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
String userKey = userSessionsKey(userId); String userKey = userSessionsKey(userId);
try { try {
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl); String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
SetOperations<String, String> sets = redisTemplate.opsForSet(); SetOperations<String, String> sets = redisTemplate.opsForSet();
@@ -54,6 +61,41 @@ public class TokenSessionStore {
} }
} }
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
Set<String> permissions, Duration ttl) {
try {
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
String accessValue = objectMapper.writeValueAsString(
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
redisTemplate.expire(userSessionsKey(userId), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) {
try {
redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode),
objectMapper.writeValueAsString(ticket), ttl);
} catch (Exception exception) {
throw unavailable(exception);
}
}
public ImpersonationTicket consumeImpersonationTicket(String rawCode) {
try {
String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode));
if (value == null || value.isBlank()) return null;
return objectMapper.readValue(value, ImpersonationTicket.class);
} catch (DataAccessException exception) {
throw unavailable(exception);
} catch (Exception exception) {
throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
}
}
public RefreshSession consume(String rawRefreshToken) { public RefreshSession consume(String rawRefreshToken) {
try { try {
String value = redisTemplate.opsForValue().getAndDelete( String value = redisTemplate.opsForValue().getAndDelete(
@@ -97,7 +139,7 @@ public class TokenSessionStore {
while (cursor.hasNext()) { while (cursor.hasNext()) {
String key = cursor.next(); String key = cursor.next();
String value = redisTemplate.opsForValue().get(key); String value = redisTemplate.opsForValue().get(key);
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) { if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
result.add(key); result.add(key);
} }
} }
@@ -112,6 +154,16 @@ public class TokenSessionStore {
catch (Exception ignored) { return false; } catch (Exception ignored) { return false; }
} }
private boolean accessBelongsTo(String value, Long userId) {
if (value == null || value.isBlank()) return false;
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
catch (Exception ignored) { return false; }
}
private static Set<String> normalized(Set<String> values) {
return values == null ? Set.of() : Set.copyOf(values);
}
private String userSessionsKey(Long userId) { private String userSessionsKey(Long userId) {
return keyPrefix + ":user-sessions:" + userId; return keyPrefix + ":user-sessions:" + userId;
} }
@@ -131,4 +183,13 @@ public class TokenSessionStore {
} }
public record RefreshSession(Long userId, String accessJwtId) { } public record RefreshSession(Long userId, String accessJwtId) { }
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
public AccessSession {
roles = normalized(roles);
permissions = normalized(permissions);
}
}
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
Long membershipId, Long actorUserId, Long actorMembershipId,
String reason) { }
} }
+10 -2
View File
@@ -34,10 +34,18 @@ baiyue.security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service} issuer: ${JWT_ISSUER:auth-service}
access-token-ttl: ${ACCESS_TOKEN_TTL:30m} audience: ${JWT_AUDIENCE:lastleg-api}
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5} max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m} lock-duration: ${LOGIN_LOCK_DURATION:15m}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
baiyue.clients: baiyue.clients:
iam: iam:
@@ -52,6 +60,6 @@ logging:
rollingpolicy: rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" 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-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}" max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,25 @@
USE baiyue_auth;
-- 可靠审计本地 Outbox;与认证业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型',
resource_type VARCHAR(64) NOT NULL COMMENT '资源类型',
resource_id VARCHAR(128) NULL COMMENT '资源ID',
service_name VARCHAR(64) NULL COMMENT '来源服务',
payload_json JSON NOT NULL COMMENT '脱敏后的审计事件',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间',
lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
lease_until DATETIME(3) NULL COMMENT '发送租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息',
dead_at DATETIME(3) NULL COMMENT '进入死信时间',
sent_at DATETIME(3) NULL COMMENT '成功发送时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
PRIMARY KEY (event_id),
KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
+10 -9
View File
@@ -8,14 +8,15 @@ CREATE TABLE IF NOT EXISTS auth_account (
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名', username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码', password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称', display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED', account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间', locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效', token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID', password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离', default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID',
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID', operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM', default_membership_id BIGINT DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空',
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间', last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)', id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文', token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
user_id BIGINT NOT NULL COMMENT '认证账号ID', user_id BIGINT NOT NULL COMMENT '认证账号ID',
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID', tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID',
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID', operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID',
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端', app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
expires_at DATETIME(3) NOT NULL COMMENT '到期时间', expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用', revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
@@ -39,6 +40,6 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
UNIQUE KEY uk_auth_refresh_hash (token_hash), UNIQUE KEY uk_auth_refresh_hash (token_hash),
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at), KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
KEY idx_auth_refresh_expire (expires_at, revoked_at) KEY idx_auth_refresh_expire (expires_at, revoked_at)
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态'; ) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态';
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。 -- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
@@ -1,4 +1,4 @@
-- Phase-one local administrator accounts. -- Phase-one local platform administrator account. Merchant accounts are created through onboarding.
-- Development-only initial password: ChangeMe123! -- Development-only initial password: ChangeMe123!
-- The BCrypt hash is intentionally stored instead of plaintext. -- The BCrypt hash is intentionally stored instead of plaintext.
USE baiyue_auth; USE baiyue_auth;
@@ -6,19 +6,18 @@ SET NAMES utf8mb4;
INSERT INTO auth_account ( INSERT INTO auth_account (
id, username, password_hash, display_name, account_status, id, username, password_hash, display_name, account_status,
failed_login_count, token_version, default_tenant_id, failed_login_count, token_version, password_change_required, default_tenant_id,
operator_tenant_id, default_membership_id, default_app_code, deleted operator_tenant_id, default_membership_id, default_app_code, deleted
) VALUES ) VALUES
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0), '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0)
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash), password_hash = VALUES(password_hash),
display_name = VALUES(display_name), display_name = VALUES(display_name),
account_status = 'ACTIVE', account_status = 'ACTIVE',
failed_login_count = 0, failed_login_count = 0,
locked_until = NULL, locked_until = NULL,
password_change_required = VALUES(password_change_required),
default_tenant_id = VALUES(default_tenant_id), default_tenant_id = VALUES(default_tenant_id),
operator_tenant_id = VALUES(operator_tenant_id), operator_tenant_id = VALUES(operator_tenant_id),
default_membership_id = VALUES(default_membership_id), default_membership_id = VALUES(default_membership_id),
@@ -0,0 +1,11 @@
USE baiyue_auth;
SET @add_password_change_required = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
AND column_name = 'password_change_required'),
'SELECT 1',
'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version');
PREPARE phase1_customer_account_sql FROM @add_password_change_required;
EXECUTE phase1_customer_account_sql;
DEALLOCATE PREPARE phase1_customer_account_sql;
@@ -0,0 +1,77 @@
package com.baiyue.auth.client;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthorizationLoaderTest {
private static final String INTERNAL_TOKEN = "test-internal-service-token-at-least-32-bytes";
@Mock private IamAuthorizationClient client;
@InjectMocks private AuthorizationLoader loader;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(loader, "internalToken", INTERNAL_TOKEN);
loader.validateConfiguration();
}
@Test
void returnsSnapshotWhenEchoedIdentityMatchesAccount() {
AuthAccount account = account();
AuthorizationSnapshot snapshot = snapshot(42L, 10001L, 9L, "MERCHANT");
when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot));
assertSame(snapshot, loader.load(account));
}
@Test
void rejectsEveryMismatchedEchoedIdentityField() {
AuthAccount account = account();
assertIdentityMismatch(account, snapshot(43L, 10001L, 9L, "MERCHANT"));
assertIdentityMismatch(account, snapshot(42L, 10002L, 9L, "MERCHANT"));
assertIdentityMismatch(account, snapshot(42L, 10001L, 10L, "MERCHANT"));
assertIdentityMismatch(account, snapshot(42L, 10001L, 9L, "PLATFORM"));
}
private void assertIdentityMismatch(AuthAccount account, AuthorizationSnapshot snapshot) {
when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot));
BizException exception = assertThrows(BizException.class, () -> loader.load(account));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
}
private AuthAccount account() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setDefaultTenantId(10001L);
account.setDefaultMembershipId(9L);
account.setDefaultAppCode("MERCHANT");
return account;
}
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
}
}
@@ -0,0 +1,15 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AuthSecurityConfigTest {
@Test
void permitsAnonymousImpersonationCodeExchange() {
assertTrue(Arrays.asList(AuthSecurityConfig.PUBLIC_AUTH_ENDPOINTS)
.contains("/api/auth/impersonations/exchange"));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class AuthTokenTtlConfigurationContractTest {
@Test
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
String configuration = Files.readString(
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
assertThat(configuration)
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class DualTrackJwtConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
java.time.Duration.ofMinutes(15)))
.withPropertyValues(
"baiyue.security.overseas-jwt.enabled=false");
@Test
void startsDomesticEncoderWithoutRsaConfiguration() {
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasBean("jwtEncoder");
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
assertThat(context).hasSingleBean(JwtEncoder.class);
});
}
}
@@ -0,0 +1,21 @@
package com.baiyue.auth.controller;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
class InternalAccountControllerConfigurationTest {
@Test
void rejectsEmptyAndShortInternalServiceTokens() {
assertTokenRejected(null);
assertTokenRejected("");
assertTokenRejected("short-token");
}
private void assertTokenRejected(String token) {
InternalAccountController controller = new InternalAccountController();
ReflectionTestUtils.setField(controller, "configuredToken", token);
assertThrows(IllegalStateException.class, controller::init);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class JwksControllerTest {
@Test
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
RSAKey current = key("current", true);
RSAKey previous = key("previous", false);
String json = new JWKSet(List.of(current, previous)).toString(false);
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
"current", Duration.ofMinutes(15));
var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
}
private RSAKey key(String kid, boolean privatePart) {
RSAKey.Builder builder = new RSAKey.Builder(
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
return builder.build();
}
}
@@ -0,0 +1,28 @@
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,35 @@
package com.baiyue.auth.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InitialAccountSeedSqlContractTest {
@Test
void initializesOnlyThePlatformAdministratorAccount() throws Exception {
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
.getContentAsString(StandardCharsets.UTF_8));
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
}
private static void assertAccountSeed(String sql) {
assertThat(sql)
.contains("(1000001, 'platform_admin'")
.contains("'平台系统管理员', 'ACTIVE'")
.contains("password_change_required")
.doesNotContain("merchant_admin")
.doesNotContain("IT_TEST")
.doesNotContain("(1000002, 'merchant_admin'")
.doesNotContain("一期演示企业管理员");
}
private static Path unifiedSql() {
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -1,6 +1,7 @@
package com.baiyue.auth.service; package com.baiyue.auth.service;
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest; import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest; import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
@@ -11,25 +12,31 @@ import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
import java.util.List;
@DisplayName("账号管理服务") @DisplayName("账号管理服务")
@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class})
class AccountManagementServiceTest { class AccountManagementServiceTest {
private final AuthAccountRepository repository = mock(AuthAccountRepository.class); @MockBean private AuthAccountRepository repository;
private final TokenSessionStore sessions = mock(TokenSessionStore.class); @MockBean private TokenSessionStore sessions;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); @Resource private PasswordEncoder encoder;
private final AccountManagementService service = new AccountManagementService(); @Resource private AccountManagementService service;
private AuthAccount account; private AuthAccount account;
@BeforeEach void setUp() { @BeforeEach void setUp() {
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1")); account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1"));
account.setTokenVersion(2); account.setAccountStatus("ACTIVE"); account.setTokenVersion(2); account.setAccountStatus("ACTIVE");
when(repository.findById(10L)).thenReturn(account); when(repository.findById(10L)).thenReturn(account);
@@ -50,6 +57,25 @@ class AccountManagementServiceTest {
verify(sessions, never()).revokeAll(any()); verify(sessions, never()).revokeAll(any());
} }
@Test void forcedPasswordChangeRequiresResetFlagAndClearsIt() {
account.setPasswordChangeRequired(true);
service.forcedChangePassword(10L, new ForcedPasswordChangeRequest("NewPass2"));
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
assertFalse(account.getPasswordChangeRequired());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void normalPasswordChangeIsRejectedUntilForcedChangeIsCompleted() {
account.setPasswordChangeRequired(true);
assertThrows(BizException.class,
() -> service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")));
verify(repository, never()).save(any());
}
@Test void disableAccountRevokesAllSessions() { @Test void disableAccountRevokesAllSessions() {
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED")); service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
assertEquals("DISABLED", account.getAccountStatus()); assertEquals("DISABLED", account.getAccountStatus());
@@ -71,6 +97,13 @@ class AccountManagementServiceTest {
assertNotEquals("Password1", captor.getValue().getPasswordHash()); assertNotEquals("Password1", captor.getValue().getPasswordHash());
} }
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
when(repository.existsByUsername("13800138000")).thenReturn(true);
assertFalse(service.usernameAvailable("13800138000"));
assertTrue(service.usernameAvailable("13900139000"));
}
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
when(repository.findById(20L)).thenReturn(null); when(repository.findById(20L)).thenReturn(null);
when(repository.existsByUsername("merchant.user")).thenReturn(false); when(repository.existsByUsername("merchant.user")).thenReturn(false);
@@ -123,4 +156,62 @@ class AccountManagementServiceTest {
verify(repository).save(account); verify(repository).save(account);
verify(sessions).revokeAll(10L); verify(sessions).revokeAll(10L);
} }
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
when(repository.findById(11L)).thenReturn(second);
service.revokeSessions(Set.of(10L, 11L));
assertEquals(3, account.getTokenVersion());
assertEquals(8, second.getTokenVersion());
verify(repository).save(account);
verify(repository).save(second);
verify(sessions).revokeAll(10L);
verify(sessions).revokeAll(11L);
}
@Test void archiveTenantDisablesEveryMerchantAccountAndRevokesSessions() {
AuthAccount owner = merchantAccount(20L, 10001L, "ACTIVE", 3);
AuthAccount member = merchantAccount(21L, 10001L, "DISABLED", null);
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of(owner, member));
service.archiveTenant(10001L);
assertEquals("DISABLED", owner.getAccountStatus());
assertEquals(4, owner.getTokenVersion());
assertEquals("DISABLED", member.getAccountStatus());
assertEquals(1, member.getTokenVersion());
verify(repository).save(owner);
verify(repository).save(member);
verify(sessions).revokeAll(20L);
verify(sessions).revokeAll(21L);
}
@Test void archiveTenantIsSuccessfulWhenCustomerHasNoAccounts() {
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of());
service.archiveTenant(10001L);
verify(repository, never()).save(any());
verify(sessions, never()).revokeAll(any());
}
private AuthAccount merchantAccount(long id, long tenantId, String status, Integer tokenVersion) {
AuthAccount value = new AuthAccount();
value.setId(id);
value.setDefaultTenantId(tenantId);
value.setDefaultAppCode("MERCHANT");
value.setAccountStatus(status);
value.setTokenVersion(tokenVersion);
return value;
}
@Configuration
static class TestConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(4);
}
}
} }
@@ -0,0 +1,197 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.AuthSecurityProperties;
import com.baiyue.auth.dto.LoginRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {AuthService.class, AuthServiceSmallJwtTest.TestConfig.class})
class AuthServiceSmallJwtTest {
@MockBean private AuthAccountRepository repository;
@MockBean private PasswordEncoder passwordEncoder;
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10));
}
}
@Test
void signsCrossRegionClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
account.setPasswordHash("hash");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(0L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(84L);
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account);
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
var response = service.login(new LoginRequest("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("user_id", 42L)
.containsEntry("userId", 42L)
.containsEntry("tenantId", 0L)
.containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L)
.containsEntry("overseas_warehouse_id", 900L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(10001L);
account.setDefaultMembershipId(94L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(3);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
var response = service.refresh("refresh-old");
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
assertThat(response.tokenType()).isEqualTo("Bearer");
assertThat(response.expiresIn()).isEqualTo(1800);
assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old");
assertThat(response.refreshExpiresIn()).isEqualTo(604800);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
verify(tokenSessionStore).consume("refresh-old");
verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(response.refreshToken()),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
void consumedRefreshTokenCannotMintAnotherDomesticOrOverseasPair() {
when(tokenSessionStore.consume("refresh-consumed")).thenReturn(null);
assertThatThrownBy(() -> service.refresh("refresh-consumed"))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("刷新令牌无效或已过期");
verify(repository, never()).findById(any());
verify(jwtEncoder, never()).encode(any());
verify(overseasTokenService, never()).issue(any(), any());
}
@Test
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
AuthAccount account = new AuthAccount();
account.setId(43L);
account.setUsername("merchant-owner");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(85L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
when(repository.findById(43L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
service.exchangeImpersonationTicket("one-time-code");
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("appCode", "MERCHANT")
.containsEntry("impersonated", true)
.containsEntry("membership_id", 85L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
}
}
@@ -0,0 +1,102 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.IamOnboardingClient;
import com.baiyue.auth.dto.MerchantRegisterRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import jakarta.annotation.Resource;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class MerchantRegistrationServiceTest {
@MockBean private AuthAccountRepository accounts;
@MockBean private PasswordEncoder encoder;
@MockBean private IamOnboardingClient onboarding;
@Resource private MerchantRegistrationService service;
@Test
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
ArgumentCaptor<AuthAccount> storedAccount = ArgumentCaptor.forClass(AuthAccount.class);
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
verify(accounts).insert(storedAccount.capture());
assertEquals(onboardingRequest.getValue().authUserId(), storedAccount.getValue().getId());
assertEquals(result.accountId(), storedAccount.getValue().getId());
assertEquals(10001L, storedAccount.getValue().getDefaultTenantId());
assertEquals(10001L, storedAccount.getValue().getOperatorTenantId());
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus());
}
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
assertThrows(IllegalStateException.class, () -> service.register(new MerchantRegisterRequest(
"failed_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
ArgumentCaptor<IamOnboardingClient.OnboardingRequest> onboardingRequest =
ArgumentCaptor.forClass(IamOnboardingClient.OnboardingRequest.class);
ArgumentCaptor<Long> compensationUserId = ArgumentCaptor.forClass(Long.class);
verify(onboarding).register(eq("internal-token"), onboardingRequest.capture());
verify(onboarding).compensate(eq("internal-token"), compensationUserId.capture());
assertEquals(onboardingRequest.getValue().authUserId(), compensationUserId.getValue());
}
@Test
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
try {
service.register(new MerchantRegisterRequest(
"rollback_owner", "Password123", "注册人", "测试企业", "张三",
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
var synchronizations = TransactionSynchronizationManager.getSynchronizations();
assertEquals(1, synchronizations.size());
synchronizations.get(0).afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
verify(onboarding).compensate(eq("internal-token"), any());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
}
@@ -0,0 +1,77 @@
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);
}
}
@@ -0,0 +1,132 @@
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");
}
}
@@ -3,9 +3,15 @@ package com.baiyue.auth.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.SetOperations;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.Duration; import java.time.Duration;
import java.util.Set; import java.util.Set;
@@ -18,32 +24,48 @@ import static org.mockito.Mockito.when;
import static org.mockito.Mockito.eq; import static org.mockito.Mockito.eq;
@DisplayName("令牌会话存储") @DisplayName("令牌会话存储")
@SpringJUnitConfig(classes = {TokenSessionStore.class, TokenSessionStoreTest.TestConfig.class})
@TestPropertySource(properties = "baiyue.security.token-key-prefix=baiyue:auth")
class TokenSessionStoreTest { class TokenSessionStoreTest {
@MockBean
private StringRedisTemplate redis;
@Resource
private TokenSessionStore store;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@Test @Test
void storesAccessAndHashedRefreshWithIndependentTtls() { void storesAccessAndHashedRefreshWithIndependentTtls() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:");
store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30)); verify(values).set(eq("baiyue:auth:access:jti-1"),
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
&& value.contains("\"userId\":42")
&& value.contains("ADMIN") && value.contains("order:read")),
eq(Duration.ofMinutes(30)));
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"), verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
} }
@Test @Test
void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception { void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
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"));
when(values.getAndDelete(anyString())).thenReturn(json); when(values.getAndDelete(anyString())).thenReturn(json);
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth");
TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
@@ -55,26 +77,23 @@ class TokenSessionStoreTest {
@Test @Test
void treatsAlreadyConsumedRefreshAsInvalid() { void treatsAlreadyConsumedRefreshAsInvalid() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class); ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values); when(redis.opsForValue()).thenReturn(values);
when(values.getAndDelete(anyString())).thenReturn(null); when(values.getAndDelete(anyString())).thenReturn(null);
assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth") assertThat(store.consume("raw-refresh")).isNull();
.consume("raw-refresh")).isNull();
} }
@Test @Test
void revokesAllIndexedSessionsForUser() { void revokesAllIndexedSessionsForUser() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
SetOperations<String, String> sets = mock(SetOperations.class); SetOperations<String, String> sets = mock(SetOperations.class);
when(redis.opsForSet()).thenReturn(sets); when(redis.opsForSet()).thenReturn(sets);
when(sets.members("baiyue:auth:user-sessions:42")) when(sets.members("baiyue:auth:user-sessions:42"))
.thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")); .thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"));
new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L); store.revokeAll(42L);
verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"))); verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")));
verify(redis).delete("baiyue:auth:user-sessions:42"); verify(redis).delete("baiyue:auth:user-sessions:42");
+11
View File
@@ -11,5 +11,16 @@
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -6,7 +6,7 @@ import java.time.Instant;
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。 * 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
*/ */
public record AuditEvent( public record AuditEvent(
String eventType, String resourceType, String resourceId, String description, String eventId, String eventType, String resourceType, String resourceId, String description,
String requestId, String traceId, Long tenantId, Long operatorTenantId, String requestId, String traceId, Long tenantId, Long operatorTenantId,
Long userId, String username, String appCode, String clientType, Long userId, String username, String appCode, String clientType,
String httpMethod, String requestUri, String clientIp, long durationMs, String httpMethod, String requestUri, String clientIp, long durationMs,
@@ -0,0 +1,62 @@
package com.baiyue.common.audit;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
/** 带租约、退避、死信和自动补偿的审计 Outbox 发布器。 */
public class AuditOutboxPublisher {
private static final Logger log = LoggerFactory.getLogger(AuditOutboxPublisher.class);
private static final int[] BACKOFF_SECONDS = {5, 15, 60, 300, 900};
private final AuditOutboxRepository repository;
private final RocketMQTemplate rocketMQ;
private final AuditProperties properties;
private final String owner;
public AuditOutboxPublisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
AuditProperties properties, String owner) {
this.repository = repository;
this.rocketMQ = rocketMQ;
this.properties = properties;
this.owner = owner;
}
@Scheduled(fixedDelayString = "${baiyue.audit.outbox.poll-interval-ms:5000}")
public void publish() {
AuditProperties.Outbox config = properties.getOutbox();
repository.releaseExpiredClaims();
repository.compensateDead(config.getCompensationDelayMinutes(), config.getMaxCompensations());
repository.deleteSent(config.getRetentionDays());
for (String eventId : repository.publishableIds(config.getBatchSize())) {
if (!repository.claim(eventId, owner, config.getLeaseSeconds())) {
continue;
}
AuditOutboxRepository.OutboxRow row = repository.find(eventId);
if (row == null) {
continue;
}
try {
var message = MessageBuilder.withPayload(row.payloadJson())
.setHeader(RocketMQHeaders.KEYS, eventId)
.setHeader("eventId", eventId)
.build();
rocketMQ.syncSend(properties.getRocketmq().getTopic() + ":AUDIT", message,
properties.getRocketmq().getSendTimeoutMs());
repository.sent(eventId, owner);
} catch (RuntimeException exception) {
int index = Math.min(Math.max(row.attemptCount(), 1) - 1, BACKOFF_SECONDS.length - 1);
repository.failed(eventId, owner, config.getMaxAttempts(), BACKOFF_SECONDS[index],
safe(exception.getMessage()));
log.warn("审计事件投递失败 eventId={} attempt={}", eventId, row.attemptCount(), exception);
}
}
}
private static String safe(String value) {
String message = value == null ? "RocketMQ 发送失败" : value;
return message.length() <= 1000 ? message : message.substring(0, 1000);
}
}
@@ -0,0 +1,94 @@
package com.baiyue.common.audit;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
/** 业务库本地审计 Outbox 仓储。 */
public class AuditOutboxRepository {
@Resource
private JdbcTemplate jdbc;
public void append(AuditEvent event, String payload) {
jdbc.update("""
INSERT INTO sys_audit_outbox
(event_id,event_type,resource_type,resource_id,service_name,payload_json,status,
attempt_count,compensation_count,next_retry_at,created_at,updated_at)
VALUES (?,?,?,?,?,CAST(? AS JSON),'PENDING',0,0,CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""", event.eventId(), event.eventType(), event.resourceType(), event.resourceId(),
event.serviceName(), payload);
}
public List<String> publishableIds(int limit) {
return jdbc.queryForList("""
SELECT event_id FROM sys_audit_outbox
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
ORDER BY next_retry_at,created_at LIMIT ?
""", String.class, limit);
}
public boolean claim(String eventId, String owner, int leaseSeconds) {
return jdbc.update("""
UPDATE sys_audit_outbox SET status='SENDING',lease_owner=?,
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE event_id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
""", owner, leaseSeconds, eventId) == 1;
}
public OutboxRow find(String eventId) {
List<OutboxRow> rows = jdbc.query(
"SELECT event_id,payload_json,attempt_count FROM sys_audit_outbox WHERE event_id=?",
(rs, rowNum) -> new OutboxRow(rs.getString(1), rs.getString(2), rs.getInt(3)), eventId);
return rows.isEmpty() ? null : rows.get(0);
}
public boolean sent(String eventId, String owner) {
return jdbc.update("""
UPDATE sys_audit_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
WHERE event_id=? AND status='SENDING' AND lease_owner=?
""", eventId, owner) == 1;
}
public boolean failed(String eventId, String owner, int maxAttempts, int backoffSeconds, String error) {
return jdbc.update("""
UPDATE sys_audit_outbox
SET status=IF(attempt_count>=?,'DEAD','PENDING'),
next_retry_at=IF(attempt_count>=?,next_retry_at,
DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND)),
dead_at=IF(attempt_count>=?,CURRENT_TIMESTAMP(3),dead_at),
lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=CURRENT_TIMESTAMP(3)
WHERE event_id=? AND status='SENDING' AND lease_owner=?
""", maxAttempts, maxAttempts, backoffSeconds, maxAttempts, error, eventId, owner) == 1;
}
public int releaseExpiredClaims() {
return jdbc.update("""
UPDATE sys_audit_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,updated_at=CURRENT_TIMESTAMP(3)
WHERE status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
""");
}
public int compensateDead(int delayMinutes, int maxCompensations) {
return jdbc.update("""
UPDATE sys_audit_outbox SET status='PENDING',attempt_count=0,
compensation_count=compensation_count+1,next_retry_at=CURRENT_TIMESTAMP(3),
dead_at=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
WHERE status='DEAD' AND compensation_count<?
AND dead_at<=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? MINUTE) LIMIT 50
""", maxCompensations, delayMinutes);
}
public int deleteSent(int retentionDays) {
return jdbc.update("""
DELETE FROM sys_audit_outbox WHERE status='SENT'
AND sent_at<DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? DAY) LIMIT 500
""", retentionDays);
}
public record OutboxRow(String eventId, String payloadJson, int attemptCount) { }
}
@@ -0,0 +1,33 @@
package com.baiyue.common.audit;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计 Outbox 的 RocketMQ 发布配置。
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
*/
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
@EnableScheduling
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
@ConditionalOnBean(type = {
"com.baiyue.common.audit.AuditOutboxRepository",
"org.apache.rocketmq.spring.core.RocketMQTemplate"
})
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "publisher-enabled", havingValue = "true")
public class AuditOutboxRocketMqAutoConfiguration {
@Bean
AuditOutboxPublisher auditOutboxPublisher(AuditOutboxRepository repository,
RocketMQTemplate rocketMQ,
AuditProperties properties,
@Value("${spring.application.name:unknown}-${random.uuid}") String owner) {
return new AuditOutboxPublisher(repository, rocketMQ, properties, owner);
}
}
@@ -11,6 +11,8 @@ public class AuditProperties {
private String endpoint; private String endpoint;
private int maxPayloadLength = 16000; private int maxPayloadLength = 16000;
private String serviceName = "unknown-service"; private String serviceName = "unknown-service";
private final Outbox outbox = new Outbox();
private final Rocketmq rocketmq = new Rocketmq();
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; }
@@ -20,4 +22,44 @@ public class AuditProperties {
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; } public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
public String getServiceName() { return serviceName; } public String getServiceName() { return serviceName; }
public void setServiceName(String serviceName) { this.serviceName = serviceName; } public void setServiceName(String serviceName) { this.serviceName = serviceName; }
public Outbox getOutbox() { return outbox; }
public Rocketmq getRocketmq() { return rocketmq; }
public static class Outbox {
private boolean enabled;
private boolean publisherEnabled;
private int batchSize = 50;
private int leaseSeconds = 60;
private int maxAttempts = 5;
private int maxCompensations = 2;
private int compensationDelayMinutes = 30;
private int retentionDays = 30;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isPublisherEnabled() { return publisherEnabled; }
public void setPublisherEnabled(boolean publisherEnabled) { this.publisherEnabled = publisherEnabled; }
public int getBatchSize() { return batchSize; }
public void setBatchSize(int batchSize) { this.batchSize = batchSize; }
public int getLeaseSeconds() { return leaseSeconds; }
public void setLeaseSeconds(int leaseSeconds) { this.leaseSeconds = leaseSeconds; }
public int getMaxAttempts() { return maxAttempts; }
public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts; }
public int getMaxCompensations() { return maxCompensations; }
public void setMaxCompensations(int maxCompensations) { this.maxCompensations = maxCompensations; }
public int getCompensationDelayMinutes() { return compensationDelayMinutes; }
public void setCompensationDelayMinutes(int compensationDelayMinutes) { this.compensationDelayMinutes = compensationDelayMinutes; }
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
}
public static class Rocketmq {
private String topic = "test-baiyue-audit-event-v1";
private int sendTimeoutMs = 3000;
public String getTopic() { return topic; }
public void setTopic(String topic) { this.topic = topic; }
public int getSendTimeoutMs() { return sendTimeoutMs; }
public void setSendTimeoutMs(int sendTimeoutMs) { this.sendTimeoutMs = sendTimeoutMs; }
}
} }
@@ -3,10 +3,17 @@ package com.baiyue.common.audit;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/** /**
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。 * 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
@@ -15,9 +22,29 @@ import org.springframework.web.client.RestClient;
@EnableConfigurationProperties(AuditProperties.class) @EnableConfigurationProperties(AuditProperties.class)
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
public class BaiyueAuditAutoConfiguration { public class BaiyueAuditAutoConfiguration {
@Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) { @Bean
return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint()); @ConditionalOnMissingBean
AuditSink auditSink(RestClient.Builder builder, AuditProperties p, ObjectMapper mapper,
ObjectProvider<AuditOutboxRepository> outbox,
ObjectProvider<PlatformTransactionManager> transactionManagers,
@Value("${baiyue.security.internal-service-token:}") String internalServiceToken) {
AuditOutboxRepository repository = outbox.getIfAvailable();
PlatformTransactionManager transactionManager = transactionManagers.getIfAvailable();
if (p.getOutbox().isEnabled() && p.getOutbox().isPublisherEnabled()
&& repository != null && transactionManager != null) {
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return new OutboxAuditSink(repository, mapper, transactions);
}
return p.getEndpoint() == null || p.getEndpoint().isBlank()
? new LoggingAuditSink()
: new HttpAuditSink(builder, p.getEndpoint(), internalServiceToken);
} }
@Bean
@ConditionalOnBean(JdbcTemplate.class)
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "enabled", havingValue = "true")
AuditOutboxRepository auditOutboxRepository() { return new AuditOutboxRepository(); }
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); } @Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
@Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); } @Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); }
} }
@@ -14,6 +14,8 @@ public @interface BusinessAudit {
String event(); String event();
String resourceType(); String resourceType();
String resourceId() default ""; // SpEL, e.g. #request.orderId String resourceId() default ""; // SpEL, e.g. #request.orderId
/** 业务数据所属客户 ID 的可选 SpEL;平台代客操作未配置时自动识别名为 tenantId 的参数。 */
String tenantId() default "";
String description() default ""; String description() default "";
boolean captureBefore() default true; boolean captureBefore() default true;
boolean captureAfter() default true; boolean captureAfter() default true;
@@ -1,8 +1,11 @@
package com.baiyue.common.audit; package com.baiyue.common.audit;
import jakarta.annotation.Resource;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.web.CorrelationContext; import com.baiyue.common.web.CorrelationContext;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Aspect;
@@ -19,7 +22,7 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.UUID;
/** /**
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。 * 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
@@ -27,19 +30,18 @@ import java.util.Arrays;
@Aspect @Aspect
public class BusinessAuditAspect { public class BusinessAuditAspect {
private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class); private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class);
private final AuditSink sink; @Resource
private final SensitiveDataSanitizer sanitizer; private AuditSink sink;
private final String serviceName; @Resource
private SensitiveDataSanitizer sanitizer;
@Value("${baiyue.audit.service-name:${spring.application.name:unknown-service}}")
private String serviceName;
private final ExpressionParser parser = new SpelExpressionParser(); private final ExpressionParser parser = new SpelExpressionParser();
public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) {
this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName;
}
@Around("@annotation(audit)") @Around("@annotation(audit)")
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable { public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
long start = System.nanoTime(); long start = System.nanoTime();
String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null; String before = audit.captureBefore() ? sanitizer.sanitize(businessArguments(pjp.getArgs())) : null;
Object result = null; Throwable failure = null; Object result = null; Throwable failure = null;
try { result = pjp.proceed(); return result; } try { result = pjp.proceed(); return result; }
catch (Throwable ex) { failure = ex; throw ex; } catch (Throwable ex) { failure = ex; throw ex; }
@@ -54,9 +56,10 @@ public class BusinessAuditAspect {
HttpServletRequest req = request(); HttpServletRequest req = request();
AuthenticatedUser user = currentUser(); AuthenticatedUser user = currentUser();
String resourceId = evaluateResourceId(pjp, a.resourceId()); String resourceId = evaluateResourceId(pjp, a.resourceId());
sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(), Long tenantId = resolveTenantId(pjp, a, user, req);
sink.publish(new AuditEvent(UUID.randomUUID().toString(), a.event(), a.resourceType(), resourceId, a.description(),
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req), req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(), tenantId,
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(), user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
user == null ? null : user.userId(), user == null ? null : user.username(), user == null ? null : user.userId(), user == null ? null : user.username(),
user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(), user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(),
@@ -76,11 +79,58 @@ public class BusinessAuditAspect {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null; return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
} }
private Object[] businessArguments(Object[] arguments) {
if (arguments == null || arguments.length == 0) return new Object[0];
return java.util.Arrays.stream(arguments)
.filter(argument -> !(argument instanceof AuthenticatedUser))
.toArray();
}
private String evaluateResourceId(ProceedingJoinPoint p, String expression) { private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
if (expression == null || expression.isBlank()) return null; if (expression == null || expression.isBlank()) return null;
Object value = evaluate(p, expression);
return value == null ? null : value.toString();
}
private Long resolveTenantId(ProceedingJoinPoint p, BusinessAudit audit, AuthenticatedUser user,
HttpServletRequest request) {
if (user == null) return headerLong(request, "X-Tenant-Id");
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
return user.tenantId();
}
Long explicit = evaluatePositiveLong(p, audit.tenantId());
if (explicit != null) return explicit;
MethodSignature signature = (MethodSignature) p.getSignature();
String[] parameterNames = signature.getParameterNames();
if (parameterNames == null) {
parameterNames = new DefaultParameterNameDiscoverer().getParameterNames(signature.getMethod());
}
Object[] arguments = p.getArgs();
if (parameterNames != null) {
for (int index = 0; index < parameterNames.length && index < arguments.length; index++) {
if ("tenantId".equals(parameterNames[index])) {
Long candidate = positiveLong(arguments[index]);
if (candidate != null) return candidate;
}
}
}
return user.tenantId();
}
private Long evaluatePositiveLong(ProceedingJoinPoint p, String expression) {
if (expression == null || expression.isBlank()) return null;
return positiveLong(evaluate(p, expression));
}
private Object evaluate(ProceedingJoinPoint p, String expression) {
MethodSignature s = (MethodSignature) p.getSignature(); MethodSignature s = (MethodSignature) p.getSignature();
var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer()); var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer());
Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString(); return parser.parseExpression(expression).getValue(ctx);
}
private static Long positiveLong(Object value) {
if (value == null) return null;
try {
long candidate = value instanceof Number number ? number.longValue() : Long.parseLong(value.toString());
return candidate > 0 ? candidate : null;
} catch (NumberFormatException ignored) {
return null;
}
} }
private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); } private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); }
private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } } private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } }
@@ -8,17 +8,28 @@ import org.springframework.web.client.RestClient;
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。 * HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
*/ */
public class HttpAuditSink implements AuditSink { public class HttpAuditSink implements AuditSink {
public static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT"); private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
private final RestClient client; private final RestClient client;
private final String endpoint; private final String endpoint;
private final String internalServiceToken;
public HttpAuditSink(RestClient.Builder builder, String endpoint) { public HttpAuditSink(RestClient.Builder builder, String endpoint, String internalServiceToken) {
this.client = builder.build(); this.client = builder.build();
this.endpoint = endpoint; this.endpoint = endpoint;
this.internalServiceToken = internalServiceToken;
} }
@Override public void publish(AuditEvent event) { @Override
try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); } public void publish(AuditEvent event) {
try {
client.post()
.uri(endpoint)
.header(INTERNAL_SERVICE_TOKEN_HEADER, internalServiceToken)
.body(event)
.retrieve()
.toBodilessEntity();
}
catch (RuntimeException ex) { catch (RuntimeException ex) {
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}", auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
ex.getClass().getSimpleName(), ex.getMessage(), event); ex.getClass().getSimpleName(), ex.getMessage(), event);
@@ -0,0 +1,33 @@
package com.baiyue.common.audit;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.support.TransactionOperations;
/** 将审计事件先持久化到业务库 Outbox,核心业务不等待 MQ。 */
public class OutboxAuditSink implements AuditSink {
private static final Logger log = LoggerFactory.getLogger(OutboxAuditSink.class);
private final AuditOutboxRepository repository;
private final ObjectMapper json;
private final TransactionOperations transactions;
public OutboxAuditSink(AuditOutboxRepository repository, ObjectMapper json,
TransactionOperations transactions) {
this.repository = repository;
this.json = json;
this.transactions = transactions;
}
@Override
public void publish(AuditEvent event) {
try {
String payload = json.writeValueAsString(event);
transactions.executeWithoutResult(status -> repository.append(event, payload));
} catch (JsonProcessingException | RuntimeException exception) {
log.error("审计事件写入本地 Outbox 失败 eventId={} eventType={}",
event.eventId(), event.eventType(), exception);
}
}
}
@@ -30,7 +30,11 @@ public class SensitiveDataSanitizer {
JsonNode root = mapper.valueToTree(value); JsonNode root = mapper.valueToTree(value);
redact(root); redact(root);
String json = mapper.writeValueAsString(root); String json = mapper.writeValueAsString(root);
return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]"; if (json.length() <= maxLength) return json;
ObjectNode truncated = mapper.createObjectNode();
truncated.put("_truncated", true);
truncated.put("_originalLength", json.length());
return mapper.writeValueAsString(truncated);
} catch (IllegalArgumentException | JsonProcessingException e) { } catch (IllegalArgumentException | JsonProcessingException e) {
return "\"[unserializable]\""; return "\"[unserializable]\"";
} }
@@ -1 +1,2 @@
com.baiyue.common.audit.BaiyueAuditAutoConfiguration com.baiyue.common.audit.BaiyueAuditAutoConfiguration
com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration
@@ -0,0 +1,110 @@
package com.baiyue.common.audit;
import com.baiyue.common.security.AuthenticatedUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class BusinessAuditAspectTest {
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable {
AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of("platform:customer:edit")), 10001L);
assertThat(event.tenantId()).isEqualTo(10001L);
assertThat(event.operatorTenantId()).isZero();
assertThat(event.userId()).isEqualTo(7L);
assertThat(event.appCode()).isEqualTo("PLATFORM");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.beforeData()).doesNotContain("platform.admin", "platform:customer:edit", "permissions");
}
@Test
void merchantOperationIgnoresTargetCustomerArgument() throws Throwable {
AuditEvent event = invoke(new AuthenticatedUser(8L, 10001L, 10001L, 2L,
"merchant.user", "MERCHANT", "WEB", Set.of()), 20002L);
assertThat(event.tenantId()).isEqualTo(10001L);
assertThat(event.operatorTenantId()).isEqualTo(10001L);
}
@Test
void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable {
AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
assertThat(event.success()).isFalse();
assertThat(event.errorCode()).isEqualTo("IllegalStateException");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.afterData()).isNull();
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, null);
}
private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, new IllegalStateException("validation failed"));
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable {
AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink);
ReflectionTestUtils.setField(aspect, "sanitizer", new SensitiveDataSanitizer(new ObjectMapper(), 1024));
ReflectionTestUtils.setField(aspect, "serviceName", "test-service");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null, List.of()));
TestOperations target = new TestOperations();
Method method = TestOperations.class.getDeclaredMethod("update", AuthenticatedUser.class, long.class);
MethodSignature signature = mock(MethodSignature.class);
when(signature.getMethod()).thenReturn(method);
when(signature.getParameterNames()).thenReturn(null);
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
else when(joinPoint.proceed()).thenThrow(failure);
try {
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
} catch (Throwable error) {
if (failure == null) throw error;
}
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture());
return event.getValue();
}
private static final class TestOperations {
@BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureAfter = false)
public String update(AuthenticatedUser user, long tenantId) {
return "ok";
}
}
}
@@ -0,0 +1,40 @@
package com.baiyue.common.audit;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.springframework.test.web.client.ExpectedCount.once;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withAccepted;
class HttpAuditSinkTest {
private static final String ENDPOINT = "http://audit-service/internal/audit/events";
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@Test
void sendsInternalServiceToken() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(once(), requestTo(ENDPOINT))
.andExpect(method(HttpMethod.POST))
.andExpect(header(HttpAuditSink.INTERNAL_SERVICE_TOKEN_HEADER, TOKEN))
.andRespond(withAccepted().contentType(MediaType.APPLICATION_JSON));
new HttpAuditSink(builder, ENDPOINT, TOKEN).publish(sampleEvent());
server.verify();
}
private AuditEvent sampleEvent() {
return new AuditEvent("audit-1", "ROLE_UPDATE", "ROLE", "1", "update role",
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
"PUT", "/api/platform/v1/roles/1", "127.0.0.1", 10L, true,
null, null, null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam");
}
}
@@ -0,0 +1,113 @@
package com.baiyue.common.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionOperations;
import java.time.Instant;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(ReliableAuditDeliveryTest.Config.class)
class ReliableAuditDeliveryTest {
@MockBean
private AuditOutboxRepository repository;
@MockBean
private RocketMQTemplate rocketMQ;
@MockBean
private TransactionOperations transactions;
@Resource
private OutboxAuditSink sink;
@Resource
private AuditOutboxPublisher publisher;
@Test
void storesEventBeforeDelivery() {
AuditEvent event = event("audit-1");
org.mockito.Mockito.doAnswer(invocation -> {
java.util.function.Consumer<?> callback = invocation.getArgument(0);
((java.util.function.Consumer<Object>) callback).accept(null);
return null;
}).when(transactions).executeWithoutResult(any());
sink.publish(event);
verify(repository).append(eq(event), anyString());
}
@Test
void marksDeliveredMessageSent() {
when(repository.publishableIds(50)).thenReturn(List.of("audit-1"));
when(repository.claim("audit-1", "test-owner", 60)).thenReturn(true);
when(repository.find("audit-1"))
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-1", "{}", 1));
publisher.publish();
verify(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
verify(repository).sent("audit-1", "test-owner");
}
@Test
void schedulesRetryAfterMqFailure() {
when(repository.publishableIds(50)).thenReturn(List.of("audit-2"));
when(repository.claim("audit-2", "test-owner", 60)).thenReturn(true);
when(repository.find("audit-2"))
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-2", "{}", 1));
doThrow(new IllegalStateException("broker unavailable"))
.when(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
publisher.publish();
verify(repository).failed("audit-2", "test-owner", 5, 5, "broker unavailable");
}
private static AuditEvent event(String id) {
return new AuditEvent(id, "ROLE_UPDATE", "ROLE", "1", "修改角色",
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
"PUT", "/roles/1", "127.0.0.1", 10, true, null, null,
null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
}
@Configuration
static class Config {
@Bean
ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
@Bean
AuditProperties properties() {
AuditProperties value = new AuditProperties();
value.getOutbox().setBatchSize(50);
value.getOutbox().setLeaseSeconds(60);
value.getOutbox().setMaxAttempts(5);
value.getRocketmq().setTopic("test-baiyue-audit-event-v1");
return value;
}
@Bean
OutboxAuditSink sink(AuditOutboxRepository repository, ObjectMapper json,
TransactionOperations transactions) {
return new OutboxAuditSink(repository, json, transactions);
}
@Bean
AuditOutboxPublisher publisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
AuditProperties properties) {
return new AuditOutboxPublisher(repository, rocketMQ, properties, "test-owner");
}
}
}
@@ -0,0 +1,38 @@
package com.baiyue.common.audit;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SensitiveDataSanitizerTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void truncatedSnapshotRemainsValidJson() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 40);
String sanitized = sanitizer.sanitize(Map.of("description", "x".repeat(200)));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
}
@Test
void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024);
String sanitized = sanitizer.sanitize(Map.of(
"profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"),
"contacts", java.util.List.of(Map.of("emailAddress", "user@example.com"))));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******");
assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******");
assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证 ## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。 - 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- `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`。
- 新能力只有被多个模块稳定复用时才放入本模块。 - 新能力只有被多个模块稳定复用时才放入本模块。
@@ -8,16 +8,27 @@ import java.util.Arrays;
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。 * 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
*/ */
public enum ResultCode { public enum ResultCode {
/** 请求处理成功。 */
SUCCESS("200", "success", HttpStatus.OK), SUCCESS("200", "success", HttpStatus.OK),
/** 请求参数不符合接口要求。 */
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST), BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
/** 未登录、令牌无效或登录态已过期。 */
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED), UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
/** 已登录但没有操作权限。 */
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN), FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
/** 请求的资源不存在或不在可访问范围内。 */
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND), NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
/** 资源版本、幂等键或当前状态发生冲突。 */
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT), CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
/** 字段校验、格式校验或业务前置校验未通过。 */
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY), VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
/** 请求频率或并发数量超过限制。 */
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS), TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
/** 未被更细错误码覆盖的通用业务失败。 */
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST), BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
/** 服务内部发生未预期异常。 */
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR), INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
/** 下游服务、数据库或基础设施暂不可用。 */
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE); SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
private final String code; private final String code;

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