Compare commits
705
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86fab838a | ||
|
|
2f1bda8369 | ||
|
|
a213be67c5 | ||
|
|
a4189a0074 | ||
|
|
a4429f95b7 | ||
|
|
5ddaa4d51f | ||
|
|
8a7c92bb0f | ||
|
|
4f2b670ebf | ||
|
|
66b9c3aa77 | ||
|
|
6a83008ba6 | ||
|
|
5f341d6fb5 | ||
|
|
8d39c61863 | ||
|
|
7e0911951d | ||
|
|
a79999d97c | ||
|
|
9b6e257f8d | ||
|
|
0103ea96c8 | ||
|
|
3975759458 | ||
|
|
e9b03a3d35 | ||
|
|
29c27d2fd7 | ||
|
|
cbcbb9c764 | ||
|
|
64047df5b3 | ||
|
|
aad8dd6344 | ||
|
|
9df87b17ac | ||
|
|
2280154773 | ||
|
|
05e3aa4e8d | ||
|
|
ef45325643 | ||
|
|
92d7c0a7b0 | ||
|
|
795e852944 | ||
|
|
6c4543c18c | ||
|
|
63182a2b88 | ||
|
|
b04c58a76a | ||
|
|
fd1a81594a | ||
|
|
5c6c0ef5ca | ||
|
|
57a2f6ef28 | ||
|
|
2ebea9e789 | ||
|
|
f2877df697 | ||
|
|
4ae236fe11 | ||
|
|
93e1540e3f | ||
|
|
3f9ee28d22 | ||
|
|
36534fe9ce | ||
|
|
1f98c5ab9c | ||
|
|
bf960f10d1 | ||
|
|
e31fd8caa2 | ||
|
|
0f4524c795 | ||
|
|
14126dce5b | ||
|
|
dc564c9530 | ||
|
|
7f05183904 | ||
|
|
4398e8b347 | ||
|
|
394c690601 | ||
|
|
fc0a6ea7ca | ||
|
|
1584c7a0a3 | ||
|
|
8eeb52ba77 | ||
|
|
9ea2aa46ec | ||
|
|
cf5d603a21 | ||
|
|
15421db421 | ||
|
|
270372f56a | ||
|
|
897ddd476f | ||
|
|
44fee01761 | ||
|
|
5d2b6c22e6 | ||
|
|
ead9565a37 | ||
|
|
3bf68c1083 | ||
|
|
6e55d94f33 | ||
|
|
7d36759f3f | ||
|
|
65c01f8b3d | ||
|
|
b6226f1ca6 | ||
|
|
ca69434698 | ||
|
|
335ad8269f | ||
|
|
7471f7e81f | ||
|
|
62407610cb | ||
|
|
0def6114ee | ||
|
|
25cb3bfa54 | ||
|
|
4e8c15dbfa | ||
|
|
3ab1a9d173 | ||
|
|
505d448850 | ||
|
|
2dcfdd5fc8 | ||
|
|
eb1a8895ac | ||
|
|
87c5e18ca6 | ||
|
|
1f6f1a0677 | ||
|
|
fa92936406 | ||
|
|
44198866d9 | ||
|
|
e815abcbd7 | ||
|
|
749a0d40a7 | ||
|
|
39988cbaa5 | ||
|
|
a067a9f056 | ||
|
|
f325d57fc1 | ||
|
|
5abc7543ae | ||
|
|
121e8ea619 | ||
|
|
7eca39e4fd | ||
|
|
f1af749616 | ||
|
|
5fb80d7624 | ||
|
|
9093550615 | ||
|
|
98766e2b1a | ||
|
|
72f3f9f971 | ||
|
|
0302985f21 | ||
|
|
6d8e4c85c5 | ||
|
|
2cfaf8e51b | ||
|
|
79eb3efe61 | ||
|
|
995c1833f8 | ||
|
|
d686b05966 | ||
|
|
4cca194eee | ||
|
|
1382902f86 | ||
|
|
12dad6673d | ||
|
|
4b5e5a1e4e | ||
|
|
0e0b6f8264 | ||
|
|
24df851acf | ||
|
|
d9c8cc478e | ||
|
|
322de03312 | ||
|
|
4fbf8bfb53 | ||
|
|
d64706e97b | ||
|
|
d066381ae2 | ||
|
|
6ba4537d3d | ||
|
|
68bdc3e6bb | ||
|
|
042d0b738a | ||
|
|
45a3b9adfd | ||
|
|
2a16c91c89 | ||
|
|
a6be840588 | ||
|
|
52b9fc382f | ||
|
|
cb78bc6b41 | ||
|
|
b67217332a | ||
|
|
b211121365 | ||
|
|
46dd0b54eb | ||
|
|
df4b7a1f6e | ||
|
|
29b2f080d8 | ||
|
|
4839243f21 | ||
|
|
c218bfad6b | ||
|
|
85a288e4f2 | ||
|
|
73d206e3f3 | ||
|
|
84ad5e779a | ||
|
|
24149c062a | ||
|
|
e94f8629d2 | ||
|
|
1c40548986 | ||
|
|
55a9d1af09 | ||
|
|
a22a284e7d | ||
|
|
058bf9f4e6 | ||
|
|
615ecb4ba1 | ||
|
|
985d952e54 | ||
|
|
bf4fd95841 | ||
|
|
1e8d85c714 | ||
|
|
0e73a79479 | ||
|
|
3fbb7653f7 | ||
|
|
8cc4c6f6da | ||
|
|
3607bebbfd | ||
|
|
015fc09091 | ||
|
|
005c3f54e2 | ||
|
|
5edc2fc1bd | ||
|
|
c93fdc44d5 | ||
|
|
9e0f41e78b | ||
|
|
a811c2ca06 | ||
|
|
ac806704be | ||
|
|
bffe7d3b23 | ||
|
|
1e4b216afa | ||
|
|
2f986cf5e3 | ||
|
|
89bd746dbd | ||
|
|
5c54785d77 | ||
|
|
aa3e72af28 | ||
|
|
551560efda | ||
|
|
7e080622f3 | ||
|
|
360115de35 | ||
|
|
111500d010 | ||
|
|
4f2abf2894 | ||
|
|
5c331f173d | ||
|
|
52e4b914b1 | ||
|
|
f4a1ec92de | ||
|
|
4893542ec4 | ||
|
|
633cec7c68 | ||
|
|
a320198b47 | ||
|
|
bb74a05562 | ||
|
|
be1eb4f45c | ||
|
|
9d09b77442 | ||
|
|
ce82916036 | ||
|
|
5a4bcf130a | ||
|
|
3d8a1fac20 | ||
|
|
01675b8220 | ||
|
|
132d96a99b | ||
|
|
8708904dab | ||
|
|
3019f5da77 | ||
|
|
8e12dd9a38 | ||
|
|
6aca41427c | ||
|
|
ef9f253f47 | ||
|
|
95b72afdcc | ||
|
|
e773816c98 | ||
|
|
e11a528dfe | ||
|
|
ce9f08c756 | ||
|
|
cd97229791 | ||
|
|
c88d99a593 | ||
|
|
a8326455fc | ||
|
|
2f32f1d827 | ||
|
|
e781cbe9cb | ||
|
|
8e61cfd202 | ||
|
|
506cc69808 | ||
|
|
f041cf524b | ||
|
|
23335ee8d0 | ||
|
|
98ec62f66e | ||
|
|
b012d64b54 | ||
|
|
9dd2fcb7e5 | ||
|
|
6a86dbcb37 | ||
|
|
6ddf0d823b | ||
|
|
b72dd54b80 | ||
|
|
92dbeb1676 | ||
|
|
4377a7acd9 | ||
|
|
2389a63858 | ||
|
|
ae3026dfdb | ||
|
|
f1639b9fe3 | ||
|
|
b3695276b3 | ||
|
|
739c20cb3d | ||
|
|
d59fccc731 | ||
|
|
4977b09af9 | ||
|
|
257c019b36 | ||
|
|
044678f65c | ||
|
|
287591c75f | ||
|
|
fe4eb0b593 | ||
|
|
9198ce02d9 | ||
|
|
a4a693837e | ||
|
|
c8574326dd | ||
|
|
938c5771b3 | ||
|
|
0fbba13714 | ||
|
|
ccfd024aef | ||
|
|
81899015cf | ||
|
|
ae9238b3cf | ||
|
|
657f3553d8 | ||
|
|
abab08e879 | ||
|
|
52b673b878 | ||
|
|
d3e16bf29f | ||
|
|
1df87e5d17 | ||
|
|
46e5c14b86 | ||
|
|
309900de82 | ||
|
|
232c2657f1 | ||
|
|
e398f1f7e0 | ||
|
|
e92cfe18ad | ||
|
|
996102e77d | ||
|
|
668668b505 | ||
|
|
fd1c582d45 | ||
|
|
0f296e016e | ||
|
|
a7ec6ed285 | ||
|
|
0d3a27f4ba | ||
|
|
dc131f26af | ||
|
|
775b2c4863 | ||
|
|
cee3ee07a8 | ||
|
|
5a78e9ca54 | ||
|
|
aa80ff4b2e | ||
|
|
7aebc3c0cb | ||
|
|
ba8a6b7832 | ||
|
|
ff9c1e2bc1 | ||
|
|
3b3d672fb1 | ||
|
|
b25087e60b | ||
|
|
85173b608d | ||
|
|
1f2c4cd8bb | ||
|
|
93feb7da67 | ||
|
|
76c3662dda | ||
|
|
af8724dd70 | ||
|
|
ab2f3ed72e | ||
|
|
15636a1600 | ||
|
|
ff926d5b74 | ||
|
|
988d1bee7c | ||
|
|
76b41c0812 | ||
|
|
4f14367eba | ||
|
|
1a67b3404d | ||
|
|
72b050af6d | ||
|
|
24e4a402a9 | ||
|
|
d1d269e96f | ||
|
|
bbd303cd65 | ||
|
|
af97217184 | ||
|
|
598749d8e4 | ||
|
|
8c280e19c4 | ||
|
|
cd477fe40a | ||
|
|
8e07342fa1 | ||
|
|
0e7df9052a | ||
|
|
e534784052 | ||
|
|
5fd00d3151 | ||
|
|
dceec6cbed | ||
|
|
e17f03e596 | ||
|
|
d5e9ba6d4c | ||
|
|
361e6be3be | ||
|
|
96feb8faff | ||
|
|
acc5bec3b1 | ||
|
|
5400a8e8e7 | ||
|
|
11cfba880d | ||
|
|
893d1801b5 | ||
|
|
d170505327 | ||
|
|
34fb91baf6 | ||
|
|
8095a99afa | ||
|
|
b0dcbf77e5 | ||
|
|
4256e74c11 | ||
|
|
12604afa36 | ||
|
|
382ffc7f55 | ||
|
|
a38396ce3f | ||
|
|
d202ca85ef | ||
|
|
1cd8b6a473 | ||
|
|
b1655a1841 | ||
|
|
1e8577dc4e | ||
|
|
5b9ae25f29 | ||
|
|
c9a05b108b | ||
|
|
09f6e28c1f | ||
|
|
1f0c0f7751 | ||
|
|
2847ad1b2d | ||
|
|
cd34cd426e | ||
|
|
b55b049e77 | ||
|
|
6c7a670f1e | ||
|
|
50d9803566 | ||
|
|
164d862588 | ||
|
|
802893b2ff | ||
|
|
b139255d3b | ||
|
|
d3183d8f2c | ||
|
|
3e517ee74e | ||
|
|
d340c418e8 | ||
|
|
3b19a1bc16 | ||
|
|
15d3ffbc0a | ||
|
|
b060be18a9 | ||
|
|
b029f4ce28 | ||
|
|
fd1da73fa8 | ||
|
|
c83620a86a | ||
|
|
b464de1edb | ||
|
|
3d57abf260 | ||
|
|
0384f54da7 | ||
|
|
bdf545ee0a | ||
|
|
0575ea247f | ||
|
|
7a5719b5a1 | ||
|
|
d36e480413 | ||
|
|
e469749a98 | ||
|
|
be5d89a4e6 | ||
|
|
7cf4a8d562 | ||
|
|
cf2356bccb | ||
|
|
f227cf05a3 | ||
|
|
f03ea227ed | ||
|
|
26eba227eb | ||
|
|
064247a8c9 | ||
|
|
f78b44297d | ||
|
|
88100f3536 | ||
|
|
63618c20dc | ||
|
|
a4ec4e8e99 | ||
|
|
3b8934fc60 | ||
|
|
6acb6178da | ||
|
|
f5c4bf130a | ||
|
|
f25e270f22 | ||
|
|
000fd10b6a | ||
|
|
23e78bb33f | ||
|
|
41d70841fc | ||
|
|
008d7ba574 | ||
|
|
b908d9f248 | ||
|
|
80527fa15f | ||
|
|
a381d443da | ||
|
|
b5864bfcda | ||
|
|
6b077c20db | ||
|
|
ff1d330c2d | ||
|
|
8845a66749 | ||
|
|
688e5649b0 | ||
|
|
d70c08a378 | ||
|
|
a9061eca54 | ||
|
|
7fe5b4e31f | ||
|
|
fa77170304 | ||
|
|
82ee80226e | ||
|
|
12e9f3746e | ||
|
|
6d5ec5759b | ||
|
|
9db371ab0e | ||
|
|
81e6610eb8 | ||
|
|
b26e64bb8d | ||
|
|
4b0b3e9172 | ||
|
|
4e4d047862 | ||
|
|
cad9a81d21 | ||
|
|
ec4b1e0fe5 | ||
|
|
3e1010fa26 | ||
|
|
ea26eecc50 | ||
|
|
092c003e3a | ||
|
|
b8c7f9fea2 | ||
|
|
45b51415cf | ||
|
|
cddd23e4c7 | ||
|
|
5199146e97 | ||
|
|
5c22018edd | ||
|
|
fd4afd6ae6 | ||
|
|
7c66097672 | ||
|
|
c71db4b481 | ||
|
|
592da4d7e2 | ||
|
|
75580c3585 | ||
|
|
68d9216cb8 | ||
|
|
5f79616623 | ||
|
|
4abcb2b80f | ||
|
|
a34b5d5ba0 | ||
|
|
b914bb7532 | ||
|
|
6739c83467 | ||
|
|
ac6cf9c904 | ||
|
|
8566c6ea9e | ||
|
|
6b416621bb | ||
|
|
d51e054def | ||
|
|
7a9c72a6a2 | ||
|
|
20d836ab3d | ||
|
|
f2a49d032a | ||
|
|
2cec065582 | ||
|
|
0e2a291e7a | ||
|
|
5e1e49085d | ||
|
|
28daf15082 | ||
|
|
05921eca13 | ||
|
|
8350bcdf00 | ||
|
|
a35794e918 | ||
|
|
5b8e7d6989 | ||
|
|
0ddffeddc8 | ||
|
|
3a9558bd1c | ||
|
|
08187363a1 | ||
|
|
46cb95db15 | ||
|
|
282eb73371 | ||
|
|
0f915d7b24 | ||
|
|
c544b1bc39 | ||
|
|
792965c822 | ||
|
|
dfa3e8cc53 | ||
|
|
fdea2a6f31 | ||
|
|
c4adba57db | ||
|
|
557ab75319 | ||
|
|
c4282020b5 | ||
|
|
555acca779 | ||
|
|
9e7cc55eab | ||
|
|
c6d7fb0d84 | ||
|
|
233f8f312d | ||
|
|
bafabf7217 | ||
|
|
e7c0788a0b | ||
|
|
c07af65c3b | ||
|
|
2bf3ff9d8e | ||
|
|
20907e4685 | ||
|
|
1982a7dd69 | ||
|
|
e4d5e6d631 | ||
|
|
5bb8f4db6d | ||
|
|
32e9b69556 | ||
|
|
1db7c3b3cf | ||
|
|
b89f14217d | ||
|
|
0c845874f4 | ||
|
|
011c30cc09 | ||
|
|
8aa58a4f72 | ||
|
|
b2a093b31b | ||
|
|
010098c4af | ||
|
|
fdd011bc57 | ||
|
|
dbca4a1403 | ||
|
|
9bb046063c | ||
|
|
7cb5da753e | ||
|
|
a4a4374c67 | ||
|
|
7184f5083a | ||
|
|
993b3dfe03 | ||
|
|
7d4256dbd2 | ||
|
|
10d3390bb1 | ||
|
|
9b36978448 | ||
|
|
e33b2c8645 | ||
|
|
2752cfb5b2 | ||
|
|
feced8eb92 | ||
|
|
87e919f3c4 | ||
|
|
0192380e81 | ||
|
|
87eb1aa72f | ||
|
|
36a048a2bc | ||
|
|
261a8bf2df | ||
|
|
06c264db0a | ||
|
|
ce6963a25e | ||
|
|
91e781b3ff | ||
|
|
66089f9b99 | ||
|
|
0bc752a2be | ||
|
|
8540147992 | ||
|
|
db80701534 | ||
|
|
274a3f34c5 | ||
|
|
5bc62a109e | ||
|
|
af646c6f1f | ||
|
|
50251240f4 | ||
|
|
808fabaf41 | ||
|
|
073cb6a577 | ||
|
|
cca1bc002e | ||
|
|
8ba04fc87d | ||
|
|
8cf6709bde | ||
|
|
f779ab56dd | ||
|
|
9eaaa9a0a0 | ||
|
|
8ff49b501f | ||
|
|
6ad438e733 | ||
|
|
885ff9dc9e | ||
|
|
e9564a21a2 | ||
|
|
c43d581ba6 | ||
|
|
b004edaf9e | ||
|
|
b780d8bad2 | ||
|
|
38e8494216 | ||
|
|
bd523036d0 | ||
|
|
6d10f9e502 | ||
|
|
226ef6a839 | ||
|
|
8bcd23f307 | ||
|
|
8b393d1906 | ||
|
|
319900e359 | ||
|
|
ef9852e757 | ||
|
|
939e90912a | ||
|
|
65cd7e0a39 | ||
|
|
bc62650ca9 | ||
|
|
18cebd3fdd | ||
|
|
1df3805334 | ||
|
|
a16b41eb8c | ||
|
|
e5fda0899e | ||
|
|
756a6b13eb | ||
|
|
e742abd1cf | ||
|
|
16dc03a2e2 | ||
|
|
5a48992d78 | ||
|
|
c331133016 | ||
|
|
af75569c78 | ||
|
|
43bbd9c1e8 | ||
|
|
74edf76e72 | ||
|
|
95849f248f | ||
|
|
f3366cb2fa | ||
|
|
0ed1531683 | ||
|
|
0380c0553b | ||
|
|
30aa0fd945 | ||
|
|
8a3f715a3b | ||
|
|
6b1d97af04 | ||
|
|
241701d9c5 | ||
|
|
47f99befb7 | ||
|
|
db86e97c0d | ||
|
|
e7dfd2790d | ||
|
|
af6d348ac3 | ||
|
|
9d863500ed | ||
|
|
f2f959e8a5 | ||
|
|
025467f9ed | ||
|
|
ae40d3f173 | ||
|
|
bac46a0f54 | ||
|
|
4063225352 | ||
|
|
5a1cd862c9 | ||
|
|
8e8e123d23 | ||
|
|
b3b8d94957 | ||
|
|
b5649cf8ae | ||
|
|
4562e3f1d1 | ||
|
|
600f3b7861 | ||
|
|
1767104406 | ||
|
|
0502ffce38 | ||
|
|
6a26b17e0a | ||
|
|
645d20665c | ||
|
|
dca1455478 | ||
|
|
4c7b0e867f | ||
|
|
cf3f9c2406 | ||
|
|
8c57f5d94e | ||
|
|
633c08045c | ||
|
|
6d50bde31e | ||
|
|
285e17f0ba | ||
|
|
0d2093054f | ||
|
|
c4e6c28da0 | ||
|
|
fcb7c11af4 | ||
|
|
d183ad6330 | ||
|
|
60895e3bf5 | ||
|
|
aaa38f825a | ||
|
|
4375beaa13 | ||
|
|
49967bbc28 | ||
|
|
584b73faf5 | ||
|
|
c2b1ab92ff | ||
|
|
d2704f68a6 | ||
|
|
505e834536 | ||
|
|
d9bbd94366 | ||
|
|
e4f65dae5f | ||
|
|
0fceece378 | ||
|
|
5a26a84f78 | ||
|
|
4d0c245b60 | ||
|
|
91c6ebca5c | ||
|
|
b90470af97 | ||
|
|
1cb4fba276 | ||
|
|
b2af5739c8 | ||
|
|
86488da6d0 | ||
|
|
5e1e281773 | ||
|
|
d7d33c4e9b | ||
|
|
1f4b09f0c9 | ||
|
|
bed917ba6c | ||
|
|
48bb752007 | ||
|
|
7c24c96f88 | ||
|
|
3809d2a028 | ||
|
|
4d49d7a2e4 | ||
|
|
bc5d9a6215 | ||
|
|
e1487162a5 | ||
|
|
7e52d0c025 | ||
|
|
8a65f18fce | ||
|
|
47c1c7922f | ||
|
|
192964419f | ||
|
|
cad2ba6e91 | ||
|
|
a02dd3a422 | ||
|
|
a59f4634cd | ||
|
|
16a2f83da6 | ||
|
|
4561245a71 | ||
|
|
386eae6785 | ||
|
|
cb30839945 | ||
|
|
bf7c214908 | ||
|
|
df0f05c74d | ||
|
|
7c2274be31 | ||
|
|
40171b7047 | ||
|
|
3f301e390b | ||
|
|
29c010e28f | ||
|
|
9b3ff06a90 | ||
|
|
9f455b6c0b | ||
|
|
7aa2b507f6 | ||
|
|
fe0f1665e5 | ||
|
|
166ebfb7ef | ||
|
|
c488c8aa02 | ||
|
|
e4372783d0 | ||
|
|
2de3fb36e9 | ||
|
|
4e79287a35 | ||
|
|
5f90bf3bc8 | ||
|
|
0e47155649 | ||
|
|
77a91b6553 | ||
|
|
f5c424b36d | ||
|
|
bceaff496e | ||
|
|
dfc1db6e8c | ||
|
|
22cebaaa7a | ||
|
|
e6eba04eda | ||
|
|
540a6f5d88 | ||
|
|
66b3a0cc9b | ||
|
|
fe60a20d95 | ||
|
|
39b9f237e0 | ||
|
|
5913c3c3ef | ||
|
|
e915086133 | ||
|
|
4630186a56 | ||
|
|
b1ca37699f | ||
|
|
4749bd3bc1 | ||
|
|
86632ef383 | ||
|
|
efcd046853 | ||
|
|
a4c47c281c | ||
|
|
3bfc11a94d | ||
|
|
84209bffd7 | ||
|
|
074a95d82b | ||
|
|
59a4eb8ed5 | ||
|
|
9ab7c01639 | ||
|
|
c9f1fe0093 | ||
|
|
013de5e921 | ||
|
|
a81f73d835 | ||
|
|
ce25a0a72d | ||
|
|
d42392dfc5 | ||
|
|
55060c7cbe | ||
|
|
d2198fff06 | ||
|
|
d15bc9b583 | ||
|
|
ac085e0f53 | ||
|
|
88390f9401 | ||
|
|
3c44f3bd51 | ||
|
|
253c0e7472 | ||
|
|
20996b050b | ||
|
|
64ba5ae249 | ||
|
|
a1bcb519c9 | ||
|
|
ea1346ee2c | ||
|
|
986e97acb6 | ||
|
|
fc554273b9 | ||
|
|
7684f3cfec | ||
|
|
14929bb257 | ||
|
|
eab51bc5ac | ||
|
|
74ce1358ae | ||
|
|
75d5fd4e26 | ||
|
|
b294695169 | ||
|
|
edd5108625 | ||
|
|
6974aeaaeb | ||
|
|
5c1c6a1a07 | ||
|
|
1200ef54ad | ||
|
|
037527b4cb | ||
|
|
047f267d4f | ||
|
|
e33fb923e4 | ||
|
|
06db4e3432 | ||
|
|
0ce264a257 | ||
|
|
b20cd9bd77 | ||
|
|
b1cd1b059d | ||
|
|
ed5b9fe77f | ||
|
|
9b88fb4b9e | ||
|
|
66d1aa0d3d | ||
|
|
789d97675b | ||
|
|
da85e30f33 | ||
|
|
2dae8b2029 | ||
|
|
e5c6e03033 | ||
|
|
aab8e37175 | ||
|
|
6b0781d120 | ||
|
|
f37ad6d83d | ||
|
|
f239bb739b | ||
|
|
0558f7ae59 | ||
|
|
57fcabe740 | ||
|
|
d49b4128bc | ||
|
|
931009a518 | ||
|
|
7b2b208467 | ||
|
|
e395292cd5 | ||
|
|
102845874b | ||
|
|
355b8629d9 | ||
|
|
13a2b90e98 | ||
|
|
87c1bfbb71 | ||
|
|
87508bba82 | ||
|
|
ef5878a85d | ||
|
|
b3d6f865b7 | ||
|
|
f690abbaeb | ||
|
|
374ceed493 | ||
|
|
3203adb5a7 | ||
|
|
96788e10de | ||
|
|
b6b5e2dc55 | ||
|
|
dca8c05d33 | ||
|
|
44bae99999 | ||
|
|
d30e975eed | ||
|
|
08f71cf17e | ||
|
|
a35cd868b9 | ||
|
|
1a2f598c9a | ||
|
|
7c0f2fc5f5 | ||
|
|
0113d19274 | ||
|
|
ef4e1d6015 | ||
|
|
d6ed4a5ad7 | ||
|
|
31d18abbf0 | ||
|
|
04c391333b | ||
|
|
74e42e91b6 | ||
|
|
92b3b9b1fb | ||
|
|
2ee78aafc2 | ||
|
|
54893aeb6b | ||
|
|
2644718b5f | ||
|
|
ced35f1600 | ||
|
|
77aa5a845e | ||
|
|
5f19cdcccb | ||
|
|
4f48015b81 | ||
|
|
38e7094520 | ||
|
|
73bfc5a65c | ||
|
|
dd8147c535 | ||
|
|
71fde99ceb | ||
|
|
fc14856ea3 | ||
|
|
0c37d40cf1 | ||
|
|
2779bf9ff7 | ||
|
|
f3c2bafec8 | ||
|
|
5951e6aa68 |
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
node_modules/
|
||||
.docker-context/
|
||||
*.log
|
||||
.idea/
|
||||
@@ -23,6 +24,9 @@ dependency-reduced-pom.xml
|
||||
reports/runtime*/
|
||||
reports/uploads/
|
||||
reports/db-backup*/
|
||||
reports/
|
||||
uploads/
|
||||
.codex-temp*
|
||||
.codex-tmp*
|
||||
.codex-*.out
|
||||
.codex-*.err
|
||||
|
||||
+57
-1
@@ -13,6 +13,55 @@ cache:
|
||||
paths:
|
||||
- .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
|
||||
script:
|
||||
@@ -25,10 +74,14 @@ cache:
|
||||
- "**/target/surefire-reports/TEST-*.xml"
|
||||
paths:
|
||||
- "**/target/surefire-reports/"
|
||||
- "**/target/site/jacoco/"
|
||||
- "**/target/jacoco.exec"
|
||||
|
||||
打包服务:
|
||||
stage: package
|
||||
needs:
|
||||
- Java拓扑合同
|
||||
- OpenAPI有效性
|
||||
- 校验与测试
|
||||
script:
|
||||
- mvn $MAVEN_CLI_OPTS -DskipTests package
|
||||
@@ -36,14 +89,17 @@ cache:
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- gateway/target/*.jar
|
||||
- gateway-overseas/target/*.jar
|
||||
- auth/target/*.jar
|
||||
- iam/target/*.jar
|
||||
- order/target/*.jar
|
||||
- firstleg/target/*.jar
|
||||
- finance/target/*.jar
|
||||
- warehouse/target/*.jar
|
||||
- warehouse-overseas-service/target/*.jar
|
||||
- lastleg-service/target/*.jar
|
||||
- file/target/*.jar
|
||||
- print/target/*.jar
|
||||
- audit-service/target/*.jar
|
||||
- integration-service/target/*.jar
|
||||
- event-bridge-service/target/*.jar
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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.
@@ -1,118 +1,107 @@
|
||||
# 柏悦头程后端项目指引
|
||||
# 头程与海外仓后端项目指引
|
||||
|
||||
## 适用范围
|
||||
## 适用范围与定位
|
||||
|
||||
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
|
||||
- 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。
|
||||
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
|
||||
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
||||
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
|
||||
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
|
||||
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
|
||||
|
||||
## 当前主体与隔离模型(重要,所有阶段必须遵守)
|
||||
## 不可突破的产品与数据边界
|
||||
|
||||
- 完整目标模型原计划为“租户—平台—客户”三级,但为尽快上线,当前约一年内采用 **业务单租户、平台管理多客户、客户严格隔离** 的模式。当前没有可管理、可选择或可切换的上级租户,也没有真实的“租户—平台—客户”三级关系。
|
||||
- “业务单租户”只是产品阶段约束,不表示代码中存在一个供所有数据共用的默认租户 ID。现有代码和数据库的 `tenant_id` 已经广泛承载 **客户 ID**,是当前客户隔离键;严禁把所有客户数据的 `tenant_id` 统一改成同一个值,否则会直接破坏客户隔离。
|
||||
- 当前需求、一期、二期、三期及后续未明确启用租户能力的开发,统一按“平台—客户”两级实现。不得自行新增租户主表、租户管理、租户切换、租户级配置、客户所属租户选择、租户级权限或其他显式多租户能力。
|
||||
- 当前产品只有“平台—客户”两级。`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。
|
||||
|
||||
当前代码中的主体标识固定按下表理解:
|
||||
## 服务路由(按关键词定位)
|
||||
|
||||
| 现有结构 | 当前业务含义 | 修改约束 |
|
||||
| 任务关键词 | 首选服务 | 先看入口 |
|
||||
|---|---|---|
|
||||
| `iam_tenant.id = 0`、`tenant_type = PLATFORM` | 平台技术作用域 | 必须保留为平台账号、平台角色和平台权限范围,不是未来的默认租户 |
|
||||
| `iam_tenant.id > 0`、`tenant_type = MERCHANT` | 平台直接服务的客户;`iam_tenant` 的这些记录当前直接作为客户主档 | 客户列表和客户业务只能使用 `MERCHANT` 记录,不得把平台记录当客户 |
|
||||
| Auth/JWT/业务表中的 `tenant_id > 0` | 当前登录客户或数据所属客户的 ID | 商户端从认证上下文取得;平台代客操作显式携带目标客户;Repository/Mapper 必须按该值过滤 |
|
||||
| RBAC 数据中的 `tenant_id = 0` | 平台权限或供客户复用的全局角色/权限模板 | 不代表真实上级租户,不能作为客户数据隔离条件 |
|
||||
| Order/Firstleg 等业务中的 `customer_id` | 订单往来单位、发货客户或其他业务客户快照,具体以模块契约为准 | 不是 IAM 平台客户 ID,不能替代 `tenant_id` 做当前客户隔离 |
|
||||
| `DEFAULT_TENANT_ID`、`public-tenant-id` 等配置 | 平台公共仓库和主数据的技术归属配置,当前固定为平台作用域 `0` | 不代表未来默认租户;公共资源不得再绑定普通客户 `10001`,变更时必须同步 Warehouse 种子、存量迁移及全部消费者 |
|
||||
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
|
||||
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
||||
| 路由、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` |
|
||||
|
||||
- 当前真正的数据隔离边界是上述客户 ID。服务端必须校验客户归属和访问权限;商户请求不得信任前端传入其他客户 ID,平台跨客户查询或代客操作必须同时受平台应用域、权限码和目标客户条件约束。
|
||||
- 平台维护并供所有客户使用的公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id = 0`;客户订单、文件、费用和其他客户私有数据继续使用客户 `tenant_id > 0`。往来单位不是客户私有数据,不能按客户复制或隔离。不得再使用演示客户 `10001` 充当公共资源所有者。
|
||||
- 代码、数据库和接口中已有的 `tenant`、`Tenant*`、`tenant_id`、`merchant` 等名称属于历史命名和兼容契约,当前不做大规模重命名;但新增代码和文档必须按本节解释其真实含义,禁止看到名称后推断系统已经存在显式租户层。
|
||||
- 预计约一年后再以独立需求引入真实租户。届时应优先新增独立租户主表或明确的上级 `root_tenant_id`,把所有存量 `MERCHANT` 客户统一归属到一个默认租户,再迁移 Auth、JWT、跨服务 DTO 和各服务数据库;不得原地把现有 `iam_tenant.id` 或业务 `tenant_id` 从客户 ID 改义为上级租户 ID。
|
||||
- 用户在后续对话明确调整本模型之前,本节是所有新模块和分期任务的事实来源;原型、历史设计或代码中的“租户”文案不得自动扩大实现范围。
|
||||
## 公共模块快速索引
|
||||
|
||||
## 模块快速索引
|
||||
|
||||
| 模块 | 职责 | 首选入口 |
|
||||
|---|---|---|
|
||||
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
|
||||
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
|
||||
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
|
||||
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` |
|
||||
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
|
||||
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
|
||||
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
|
||||
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
|
||||
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
|
||||
| `iam/` | 单租户模式下的客户、客户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照;部分代码仍保留 `Tenant*` 历史命名 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
|
||||
| `order/` | 订单主数据、客户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox;客户之间按归属隔离,部分字段/接口仍保留 `tenant` 历史命名 | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
|
||||
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
|
||||
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司、海外仓阶梯费用;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`、`PlatformOverseasStorageFeeController.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
|
||||
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
|
||||
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
|
||||
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
|
||||
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
|
||||
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
|
||||
|
||||
## 高频业务检索入口
|
||||
|
||||
| 搜索目标 | 先读 |
|
||||
| 关键词 | 模块与入口 |
|
||||
|---|---|
|
||||
| 后台用户管理、平台/客户账号、跨客户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 |
|
||||
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` |
|
||||
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 |
|
||||
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
|
||||
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
|
||||
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
|
||||
| 海外仓阶梯费用 | `warehouse/.../controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;表为 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
|
||||
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
|
||||
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
|
||||
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
|
||||
| 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 |
|
||||
| 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 |
|
||||
| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 |
|
||||
| JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 |
|
||||
| Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 |
|
||||
| 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 |
|
||||
| 跨区域事件信封、Payload 与哈希 | `common-event-contract/`;继续读该目录 `AGENTS.md` |
|
||||
|
||||
## 二期入库快速索引
|
||||
## 全量验收路由
|
||||
|
||||
| 业务链路 | API 与编排入口 | 数据、跨服务与验证入口 |
|
||||
|---|---|---|
|
||||
| 平台入库单列表、页签与聚合详情 | `firstleg/.../controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
|
||||
| 平台测量草稿、提交及重量 OCR 结果 | `firstleg/.../controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | 图片走 `client/MeasurementImageClient.java`,托盘库位走 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
|
||||
| 第三方尺寸测量回传 | `firstleg/.../controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 订单货物校验走 `client/OrderCargoClient.java` → `order/.../controller/InternalOrderCargoController.java`;证据图片走 File 内部接口;配置 `config/MeasurementOpenApiProperties.java`,测试 `service/InboundMeasurementOpenApiServiceTest.java` |
|
||||
| 测量审核、异常放行与自动完成入库 | `firstleg/.../controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` → `AutomaticInboundCompletionService.java` | 费用与入库状态编排在 `FbaPricingService.java`、`InboundCompletionOrchestrator.java`;测试 `service/MeasurementReviewServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
|
||||
| FBA 客户规则、承运商价格与费用快照 | 主数据 `firstleg/.../controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java`;计费 `PlatformFbaPricingController.java` → `service/FbaPricingService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`、`src/main/resources/db/phase2-fba-pricing-migration.sql`;测试 `service/FbaMasterDataServiceTest.java`、`FbaPricingServiceTest.java` |
|
||||
| 客户查看入库费用 | `firstleg/.../controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 只按已认证客户查询;`Merchant*` 及 `/api/merchant/` 为历史兼容命名,接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` |
|
||||
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
|
||||
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
|
||||
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
|
||||
| 数据库部署 | 空库/存量环境唯一入口 `deploy/sql/头程数据库统一增量-MySQL8.sql` | 覆盖全部一期、二期及后续增量;可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,历史日期脚本不得再单独执行 |
|
||||
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
|
||||
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
|
||||
|
||||
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
|
||||
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
|
||||
## 国内头程与海外仓协同
|
||||
|
||||
## 配置、数据库与验证入口
|
||||
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
||||
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
|
||||
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
||||
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
|
||||
|
||||
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
|
||||
- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。
|
||||
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
|
||||
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
|
||||
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
|
||||
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
|
||||
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
|
||||
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
|
||||
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
|
||||
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
|
||||
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
|
||||
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
|
||||
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
|
||||
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
|
||||
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
## 通用修改约束
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 修改 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`。实际部署仍须当前对话明确授权。
|
||||
|
||||
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
|
||||
- 当前采用业务单租户、平台管理多客户模式。现有 `tenant_id` 是客户归属与隔离键,客户数据必须保留该维度,并由服务端执行平台/客户访问域、客户数据隔离、数据范围和按钮权限校验;不得把它统一替换为所谓默认租户 ID,也不得据此允许跨客户访问。
|
||||
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
|
||||
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
|
||||
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
|
||||
## 全量 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 通过不代表视觉验收完成。
|
||||
|
||||
- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。
|
||||
- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。
|
||||
## 阶段文档入口
|
||||
|
||||
- 一期:`../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
@@ -1,33 +1,112 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceCheckboxes = [
|
||||
'gateway': 'SERVICE_GATEWAY',
|
||||
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
|
||||
'auth': 'SERVICE_AUTH',
|
||||
'iam': 'SERVICE_IAM',
|
||||
'firstleg': 'SERVICE_FIRSTLEG',
|
||||
'finance': 'SERVICE_FINANCE',
|
||||
'order': 'SERVICE_ORDER',
|
||||
'warehouse': 'SERVICE_WAREHOUSE',
|
||||
'file': 'SERVICE_FILE',
|
||||
'print': 'SERVICE_PRINT',
|
||||
'integration-service': 'SERVICE_INTEGRATION',
|
||||
'lastleg-service': 'SERVICE_LASTLEG',
|
||||
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
|
||||
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
|
||||
'audit-service': 'SERVICE_AUDIT'
|
||||
]
|
||||
|
||||
def commonPaths = [
|
||||
'pom.xml', 'platform-bom/', 'common-core/', 'common-web/', 'common-security/',
|
||||
'common-openapi/', 'common-audit/', 'common-event-contract/', 'Jenkinsfile',
|
||||
'Jenkinsfile.service', 'deploy/'
|
||||
def fullRebuildPaths = [
|
||||
'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/',
|
||||
'deploy/docker-compose.yml', 'deploy/service-map.yml'
|
||||
]
|
||||
|
||||
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 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 {
|
||||
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
|
||||
agent any
|
||||
agent none
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
@@ -38,42 +117,50 @@ pipeline {
|
||||
parameters {
|
||||
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
|
||||
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
|
||||
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
|
||||
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
||||
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
||||
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
|
||||
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
|
||||
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
|
||||
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
||||
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
|
||||
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)')
|
||||
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: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
|
||||
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: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth')
|
||||
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 基础镜像')
|
||||
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: downstreamJobPrefixForJob(env.JOB_BASE_NAME), description: '单服务 Jenkins Job 名称前缀;开发总控自动使用 dev 专属 Job')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('确定构建范围') {
|
||||
agent { label 'java-builder' }
|
||||
steps {
|
||||
script {
|
||||
String requestedRepository
|
||||
if (params.ENVIRONMENT == 'test') {
|
||||
requestedRepository = 'baiyue-firstleg-test'
|
||||
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry'
|
||||
} else {
|
||||
requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
|
||||
error('预发布/生产环境必须填写真实镜像仓库地址')
|
||||
}
|
||||
if (requestedRepository.contains('://') || !requestedRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 格式错误:请填写 host[:port]/owner/image,不要包含 http:// 或 https://')
|
||||
}
|
||||
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
|
||||
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
|
||||
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
|
||||
? 'development'
|
||||
: params.ENVIRONMENT
|
||||
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
|
||||
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
|
||||
|
||||
String requestedRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
|
||||
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
|
||||
|
||||
String jobPrefix = params.DOWNSTREAM_JOB_PREFIX?.trim()
|
||||
@@ -81,7 +168,13 @@ pipeline {
|
||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
||||
}
|
||||
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 = []
|
||||
if (params.BUILD_MODE == 'ALL') {
|
||||
selected = serviceModules
|
||||
@@ -93,18 +186,45 @@ pipeline {
|
||||
error('SELECT 模式至少需要勾选一个服务')
|
||||
}
|
||||
} 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) {
|
||||
selected = serviceModules
|
||||
echo 'AUTO 未找到可用基线,安全回退为全量构建'
|
||||
} else {
|
||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||
boolean commonChanged = changed.any { path ->
|
||||
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||
boolean databaseChanged = changed.any { path ->
|
||||
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||
}
|
||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||
changed.any { it == module || it.startsWith("${module}/") }
|
||||
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||
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()
|
||||
if (params.DEPLOY && selected.contains('event-bridge-service')) {
|
||||
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||
}
|
||||
if (['BOTH', 'SELECT'].contains(bridgeRegion) &&
|
||||
!['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
|
||||
error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS')
|
||||
}
|
||||
if (bridgeRegion == 'SELECT') {
|
||||
echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥'
|
||||
}
|
||||
}
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = '没有需要构建的业务服务'
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS"
|
||||
: (env.DATABASE_CHANGE_DETECTED == 'true'
|
||||
? '仅数据库迁移变更 | 需单独审核执行'
|
||||
: '没有需要构建的业务服务')
|
||||
} else {
|
||||
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()
|
||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}"
|
||||
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | PENDING ${env.SELECTED_SERVICES}${databaseSuffix}"
|
||||
: "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +272,7 @@ pipeline {
|
||||
stage('生产环境部署确认(仅 production 执行)') {
|
||||
when {
|
||||
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 {
|
||||
@@ -139,14 +280,30 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('测试数据库初始化(可选)') {
|
||||
stage('统一 Maven 测试门禁') {
|
||||
when {
|
||||
allOf {
|
||||
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
|
||||
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
|
||||
}
|
||||
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 {
|
||||
sh 'sh deploy/scripts/initialize-test-databases.sh'
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +316,14 @@ pipeline {
|
||||
String service = selectedService
|
||||
branches[service] = {
|
||||
String jobName = "${env.RESOLVED_JOB_PREFIX}${service}"
|
||||
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)
|
||||
String downstreamImageTag = deployBothBridges
|
||||
? "${env.IMAGE_TAG}-both"
|
||||
: env.IMAGE_TAG
|
||||
String downstreamBridgeRegion = deployBothBridges
|
||||
? 'CN'
|
||||
: (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN')
|
||||
echo "触发单服务 Job: ${jobName}"
|
||||
def downstreamBuild = build(
|
||||
job: jobName,
|
||||
@@ -166,12 +331,16 @@ pipeline {
|
||||
propagate: false,
|
||||
parameters: [
|
||||
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
|
||||
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
|
||||
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT),
|
||||
string(name: 'IMAGE_TAG', value: downstreamImageTag),
|
||||
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
|
||||
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: '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'
|
||||
@@ -191,7 +360,9 @@ pipeline {
|
||||
.collect { service, result -> "${service}=${result}" }
|
||||
String summary = downstreamResults.collect { service, result -> "${service}=${result}" }.join(', ')
|
||||
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) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
@@ -200,7 +371,4 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always { cleanWs(deleteDirs: true, notFailBuild: true) }
|
||||
}
|
||||
}
|
||||
|
||||
+214
-96
@@ -1,29 +1,29 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceJars = [
|
||||
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
|
||||
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
|
||||
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
||||
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
||||
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
||||
'finance': 'finance/target/finance-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',
|
||||
'file': 'file/target/file-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',
|
||||
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
|
||||
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
|
||||
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
|
||||
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
||||
]
|
||||
|
||||
pipeline {
|
||||
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。
|
||||
agent any
|
||||
|
||||
tools {
|
||||
maven 'maven-3.8.8'
|
||||
}
|
||||
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
|
||||
agent none
|
||||
|
||||
options {
|
||||
skipDefaultCheckout(true)
|
||||
@@ -35,110 +35,202 @@ pipeline {
|
||||
parameters {
|
||||
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
|
||||
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: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值')
|
||||
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 {
|
||||
REGISTRY_CREDENTIALS = 'gitea-test-token'
|
||||
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
|
||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
||||
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
|
||||
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('校验参数并检出指定提交') {
|
||||
steps {
|
||||
checkout scm
|
||||
stage('构建并发布单服务') {
|
||||
agent { label 'java-builder' }
|
||||
tools { maven 'maven-3.8.8' }
|
||||
stages {
|
||||
stage('校验参数并检出指定提交') {
|
||||
steps {
|
||||
checkout scm
|
||||
|
||||
script {
|
||||
String service = serviceModules.find { candidate ->
|
||||
env.JOB_BASE_NAME?.endsWith("-${candidate}")
|
||||
}
|
||||
String sourceCommit = params.SOURCE_COMMIT?.trim()
|
||||
String imageTag = params.IMAGE_TAG?.trim()
|
||||
String imageRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
script {
|
||||
String service = serviceModules.find { candidate ->
|
||||
env.JOB_BASE_NAME?.endsWith("-${candidate}")
|
||||
}
|
||||
String sourceCommit = params.SOURCE_COMMIT?.trim()
|
||||
String imageTag = params.IMAGE_TAG?.trim()
|
||||
String imageRepository = params.IMAGE_REPOSITORY?.trim()
|
||||
|
||||
if (!sourceCommit) {
|
||||
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
}
|
||||
if (!imageTag) {
|
||||
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
}
|
||||
if (!sourceCommit) {
|
||||
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
}
|
||||
if (!imageTag) {
|
||||
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||
}
|
||||
|
||||
if (!serviceModules.contains(service)) {
|
||||
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
||||
}
|
||||
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||||
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
||||
}
|
||||
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
|
||||
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
|
||||
}
|
||||
if (!imageRepository || imageRepository.contains('://')) {
|
||||
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://')
|
||||
}
|
||||
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) {
|
||||
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
}
|
||||
if (!serviceModules.contains(service)) {
|
||||
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
|
||||
}
|
||||
boolean deployBothBridges = service == 'event-bridge-service' &&
|
||||
imageTag?.endsWith('-both')
|
||||
if (deployBothBridges) {
|
||||
imageTag = imageTag.substring(0, imageTag.length() - '-both'.length())
|
||||
}
|
||||
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
|
||||
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
|
||||
}
|
||||
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
|
||||
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
|
||||
}
|
||||
if (!imageRepository || imageRepository.contains('://')) {
|
||||
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
|
||||
}
|
||||
if (!imageRepository.contains('/')) {
|
||||
error('IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
|
||||
}
|
||||
|
||||
env.RESOLVED_SERVICE = service
|
||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||
env.RESOLVED_IMAGE_TAG = imageTag
|
||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||
env.RESOLVED_SERVICE = service
|
||||
env.RESOLVED_SOURCE_COMMIT = sourceCommit
|
||||
env.RESOLVED_IMAGE_TAG = imageTag
|
||||
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
|
||||
env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false'
|
||||
|
||||
if (service == 'event-bridge-service' && params.DEPLOY) {
|
||||
String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim()
|
||||
if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) {
|
||||
error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS')
|
||||
}
|
||||
if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) &&
|
||||
!['development', 'test'].contains(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 '''
|
||||
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}")"
|
||||
'''
|
||||
stage('Maven 测试与打包') {
|
||||
steps {
|
||||
script {
|
||||
boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean
|
||||
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) {
|
||||
error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包')
|
||||
}
|
||||
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 {
|
||||
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}"
|
||||
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}"
|
||||
stage('构建并发布单服务镜像') {
|
||||
steps {
|
||||
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 {
|
||||
script {
|
||||
String testOption = params.SKIP_TESTS ? '-DskipTests' : ''
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建并发布单服务镜像') {
|
||||
steps {
|
||||
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]
|
||||
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
|
||||
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
|
||||
|
||||
if (params.ENVIRONMENT == 'test') {
|
||||
echo "测试镜像已保存在当前 Docker 节点:${image}"
|
||||
} else {
|
||||
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
||||
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 push ${image}"
|
||||
} finally {
|
||||
sh "docker logout ${registryHost} || true"
|
||||
String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot"
|
||||
String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy"
|
||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
||||
String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test'
|
||||
// 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。
|
||||
// resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。
|
||||
lock(resource: serviceDeployResource, inversePrecedence: true) {
|
||||
lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) {
|
||||
node(targetLabel) {
|
||||
deleteDir()
|
||||
try {
|
||||
unstash 'deployment-files'
|
||||
echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}"
|
||||
List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' &&
|
||||
(env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' ||
|
||||
['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION)))
|
||||
? ['CN', 'OVERSEAS']
|
||||
: [params.EVENT_BRIDGE_REGION]
|
||||
withCredentials([
|
||||
file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'),
|
||||
usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS,
|
||||
usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')
|
||||
]) {
|
||||
bridgeRegions.each { bridgeRegion ->
|
||||
if (env.RESOLVED_SERVICE == 'event-bridge-service') {
|
||||
echo "部署事件桥实例: ${bridgeRegion}"
|
||||
}
|
||||
withEnv([
|
||||
'TARGET_IS_LOCAL=true',
|
||||
"EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}"
|
||||
]) {
|
||||
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,15 +239,44 @@ pipeline {
|
||||
}
|
||||
|
||||
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 {
|
||||
deleteDir()
|
||||
unstash 'deployment-files'
|
||||
script {
|
||||
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
|
||||
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\""
|
||||
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
|
||||
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 {
|
||||
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
|
||||
}
|
||||
always {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 柏悦头程平台
|
||||
|
||||
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
||||
本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。
|
||||
|
||||
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
||||
|
||||
@@ -12,20 +12,24 @@
|
||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||
- `warehouse`:国内仓储能力。
|
||||
- `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` 发布的公共制品。
|
||||
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
|
||||
- `platform-bom`:统一管理仓库内公共依赖版本。
|
||||
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
mvn -DskipTests validate
|
||||
node --test scripts/java-topology-contract.test.mjs
|
||||
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`。
|
||||
|
||||
本地一期完整联调启动顺序为: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 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||
|
||||
@@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
|
||||
|
||||
## 仓库边界
|
||||
|
||||
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|---|---|
|
||||
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
|
||||
| 内部接收 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` |
|
||||
| 入库编排 | `service/AuditIngestService.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。
|
||||
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
|
||||
- 入库前确保密码、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`,并按改动验证接收和持久化结果。
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.baiyue.audit;
|
||||
|
||||
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
*/
|
||||
@MapperScan("com.baiyue.audit.mapper")
|
||||
@EnableScheduling
|
||||
@Import(MybatisPlusConfiguration.class)
|
||||
@SpringBootApplication
|
||||
public class BaiyueAuditApplication {
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
|
||||
String username,
|
||||
String appCode,
|
||||
String clientType,
|
||||
String clientIp,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String serviceName,
|
||||
|
||||
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
|
||||
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.getRequestId(),
|
||||
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
|
||||
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
|
||||
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
|
||||
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
|
||||
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
|
||||
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID',
|
||||
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID',
|
||||
tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0',
|
||||
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0',
|
||||
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
||||
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
|
||||
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
|
||||
@@ -38,17 +38,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||
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,
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
lease_owner VARCHAR(128) NULL,
|
||||
lease_until DATETIME(3) NULL,
|
||||
last_error VARCHAR(1000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
processed_at DATETIME(3) NULL,
|
||||
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)
|
||||
|
||||
@@ -22,17 +22,17 @@ 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,
|
||||
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,
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
lease_owner VARCHAR(128) NULL,
|
||||
lease_until DATETIME(3) NULL,
|
||||
last_error VARCHAR(1000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
processed_at DATETIME(3) NULL,
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
|
||||
.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));
|
||||
|
||||
+27
-24
@@ -1,32 +1,35 @@
|
||||
# Auth Service 模块指引
|
||||
|
||||
## 职责边界
|
||||
## 职责与边界
|
||||
|
||||
- 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。
|
||||
- 客户主档、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。现有账号/JWT 中的 `tenant_id > 0` 当前是客户 ID,`tenant_id=0` 是平台技术作用域;字段名保持兼容,不代表已经存在真实租户层。
|
||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||
- 客户主档、成员、角色权限归 `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` |
|
||||
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) |
|
||||
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 |
|
||||
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` |
|
||||
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 |
|
||||
| 商户注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
|
||||
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
|
||||
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、兼容字段 `tenantId`、成员与应用端 |
|
||||
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
|
||||
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
|
||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
|
||||
| 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、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
|
||||
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。
|
||||
- 内部账号创建允许相同账号 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、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
|
||||
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||
|
||||
## 全量验收导航
|
||||
|
||||
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 国内与海外 JWT/JWKS 冻结契约
|
||||
|
||||
## 双轨边界
|
||||
|
||||
### 国内现有链路
|
||||
|
||||
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
|
||||
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
|
||||
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
|
||||
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
|
||||
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
|
||||
|
||||
### 海外尾程链路
|
||||
|
||||
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
|
||||
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
|
||||
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
|
||||
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
|
||||
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
|
||||
|
||||
## 海外签发契约
|
||||
|
||||
- `issuer`:`auth-service`
|
||||
- `audience`:`lastleg-api`
|
||||
- 算法白名单:仅 `RS256`
|
||||
- Access Token 默认有效期:15 分钟
|
||||
- 兑换接口:`POST /api/auth/overseas/token`
|
||||
- JWKS 路径:`/api/auth/.well-known/jwks.json`
|
||||
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
|
||||
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
|
||||
|
||||
海外 Token 的 JSON 声明名称和类型固定如下:
|
||||
|
||||
| 声明 | JSON 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `sub` | string | 用户 ID 的字符串形式 |
|
||||
| `jti` | string | Token 唯一标识 |
|
||||
| `iat` | number | 签发时间 |
|
||||
| `exp` | number | 过期时间 |
|
||||
| `aud` | string array | 固定包含 `lastleg-api` |
|
||||
| `userId` | number | 认证用户 ID |
|
||||
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
|
||||
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
|
||||
| `roles` | string array | 当前角色编码快照 |
|
||||
| `authorities` | string array | 当前权限编码快照 |
|
||||
| `permissionVersion` | number | IAM 单调权限版本 |
|
||||
|
||||
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
|
||||
|
||||
## 海外配置
|
||||
|
||||
```yaml
|
||||
baiyue:
|
||||
security:
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
```
|
||||
|
||||
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
|
||||
|
||||
## 密钥轮换
|
||||
|
||||
- JWK Set 只允许 RSA 密钥。
|
||||
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
|
||||
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
|
||||
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
|
||||
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
|
||||
|
||||
## IAM 安全事件
|
||||
|
||||
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
|
||||
@@ -13,7 +13,7 @@ import org.springframework.stereotype.Component;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。
|
||||
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationLoader {
|
||||
|
||||
@@ -20,4 +20,13 @@ public class AuthorizationSnapshot {
|
||||
private String clientType;
|
||||
private Set<String> roles;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@ public interface IamOnboardingClient {
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
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;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
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.password.PasswordEncoder;
|
||||
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.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||
@@ -25,15 +21,21 @@ import java.nio.charset.StandardCharsets;
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||
public class AuthSecurityConfig {
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||
static final String[] PUBLIC_AUTH_ENDPOINTS = {
|
||||
"/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
|
||||
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));
|
||||
}
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||
@@ -47,8 +49,7 @@ public class AuthSecurityConfig {
|
||||
.exceptionHandling(errors -> errors
|
||||
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout",
|
||||
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers(PUBLIC_AUTH_ENDPOINTS).permitAll()
|
||||
.requestMatchers("/internal/auth/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.time.Duration;
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
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.service.MerchantRegistrationService;
|
||||
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.responses.ApiResponses;
|
||||
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 com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.OverseasTokenService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
@@ -34,16 +40,40 @@ public class AuthController {
|
||||
private MerchantRegistrationService registrationService;
|
||||
@Resource
|
||||
private AccountManagementService accountManagementService;
|
||||
@Resource
|
||||
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||
|
||||
@PostMapping("/overseas/token")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
|
||||
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
|
||||
return ApiResponse.success(service.exchange(user.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/password")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
|
||||
accountManagementService.changePassword(user.userId(), request);
|
||||
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
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
@@ -82,4 +112,11 @@ public class AuthController {
|
||||
authService.logout(request.refreshToken());
|
||||
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.service.AccountManagementService;
|
||||
import com.baiyue.auth.service.AuthService;
|
||||
import com.baiyue.auth.service.OverseasSessionValidationService;
|
||||
import com.baiyue.auth.dto.OverseasSessionValidationDtos;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -17,9 +22,14 @@ import java.security.MessageDigest;
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs")
|
||||
public class InternalAccountController {
|
||||
@Resource
|
||||
private AccountManagementService service;
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private OverseasSessionValidationService overseasSessionValidationService;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String configuredToken;
|
||||
@@ -55,6 +65,21 @@ public class InternalAccountController {
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
|
||||
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")
|
||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||
@@ -63,7 +88,20 @@ public class InternalAccountController {
|
||||
@PutMapping("/{id}/password")
|
||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@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")
|
||||
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.NotNull;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
/** 账号安全及内部账号管理契约。 */
|
||||
public final class AccountManagementDtos {
|
||||
@@ -14,6 +16,10 @@ public final class AccountManagementDtos {
|
||||
public record ChangePasswordRequest(@NotBlank String oldPassword,
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@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,
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||
@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 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)
|
||||
@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,
|
||||
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
||||
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
||||
|
||||
@@ -5,5 +5,6 @@ public record MerchantRegisterResponse(
|
||||
Long tenantId,
|
||||
Long membershipId,
|
||||
String tenantStatus,
|
||||
String dimensionInitializationStatus,
|
||||
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 = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
||||
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
|
||||
@Schema(description = "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 LocalDateTime lockedUntil;
|
||||
private Integer tokenVersion;
|
||||
private Boolean passwordChangeRequired;
|
||||
private Long defaultTenantId;
|
||||
private Long operatorTenantId;
|
||||
private Long defaultMembershipId;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.mapper.AuthAccountMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
||||
@@ -32,6 +33,12 @@ public class AuthAccountRepository {
|
||||
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) {
|
||||
mapper.updateById(account);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
|
||||
@Service
|
||||
@@ -29,13 +30,30 @@ public class AccountManagementService {
|
||||
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
||||
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
|
||||
}
|
||||
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
|
||||
}
|
||||
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
||||
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
|
||||
@@ -81,6 +99,14 @@ public class AccountManagementService {
|
||||
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
|
||||
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||
description = "同步账号显示名称", captureAfter = false)
|
||||
@@ -93,8 +119,21 @@ public class AccountManagementService {
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request) {
|
||||
setPasswordAndRevoke(required(accountId), request.newPassword());
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||
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
|
||||
@@ -125,10 +164,11 @@ public class AccountManagementService {
|
||||
|
||||
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.setFailedLoginCount(0);
|
||||
account.setLockedUntil(null);
|
||||
account.setPasswordChangeRequired(forceChangePassword);
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
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.core.api.ResultCode;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
@@ -45,6 +50,8 @@ public class AuthService {
|
||||
private AuthorizationLoader authorizationLoader;
|
||||
@Resource
|
||||
private TokenSessionStore tokenSessionStore;
|
||||
@Resource
|
||||
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@@ -104,30 +111,103 @@ public class AuthService {
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
JwtClaimsSet.Builder claimsBuilder = 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", account.getDefaultAppCode())
|
||||
.claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("permissions", authorization.getPermissions())
|
||||
.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(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
signingHeader(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(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());
|
||||
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||
OverseasTokenService.OverseasTokenResponse overseas = service == null
|
||||
? null : service.issue(account, authorization);
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||
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() {
|
||||
|
||||
@@ -103,7 +103,8 @@ public class MerchantRegistrationService {
|
||||
throw exception;
|
||||
}
|
||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -41,13 +41,15 @@ public class TokenSessionStore {
|
||||
keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
}
|
||||
|
||||
public void store(String jwtId, Long userId, String rawRefreshToken,
|
||||
Duration accessTtl, Duration refreshTtl) {
|
||||
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
|
||||
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||
String userKey = userSessionsKey(userId);
|
||||
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));
|
||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||
@@ -59,6 +61,41 @@ public class TokenSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
|
||||
Set<String> permissions, Duration ttl) {
|
||||
try {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String accessValue = objectMapper.writeValueAsString(
|
||||
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
|
||||
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
|
||||
redisTemplate.expire(userSessionsKey(userId), ttl);
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode),
|
||||
objectMapper.writeValueAsString(ticket), ttl);
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public ImpersonationTicket consumeImpersonationTicket(String rawCode) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode));
|
||||
if (value == null || value.isBlank()) return null;
|
||||
return objectMapper.readValue(value, ImpersonationTicket.class);
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
|
||||
}
|
||||
}
|
||||
|
||||
public RefreshSession consume(String rawRefreshToken) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().getAndDelete(
|
||||
@@ -102,7 +139,7 @@ public class TokenSessionStore {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +154,16 @@ public class TokenSessionStore {
|
||||
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) {
|
||||
return keyPrefix + ":user-sessions:" + userId;
|
||||
}
|
||||
@@ -136,4 +183,13 @@ public class TokenSessionStore {
|
||||
}
|
||||
|
||||
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) { }
|
||||
}
|
||||
|
||||
@@ -34,10 +34,18 @@ baiyue.security:
|
||||
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}
|
||||
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}
|
||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
overseas-jwt:
|
||||
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||
|
||||
baiyue.clients:
|
||||
iam:
|
||||
|
||||
@@ -8,17 +8,17 @@ CREATE TABLE IF NOT EXISTS sys_audit_outbox (
|
||||
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',
|
||||
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),
|
||||
lease_owner VARCHAR(128) NULL,
|
||||
lease_until DATETIME(3) NULL,
|
||||
last_error VARCHAR(1000) NULL,
|
||||
dead_at DATETIME(3) NULL,
|
||||
sent_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
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)
|
||||
|
||||
@@ -8,14 +8,15 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
||||
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED',
|
||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
||||
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
|
||||
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
|
||||
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
|
||||
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID',
|
||||
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM',
|
||||
password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
|
||||
default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离',
|
||||
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 '逻辑删除标记',
|
||||
last_login_at DATETIME(3) DEFAULT NULL 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)',
|
||||
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
|
||||
user_id BIGINT NOT NULL COMMENT '认证账号ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID',
|
||||
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端',
|
||||
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID',
|
||||
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
|
||||
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
|
||||
revoked_at DATETIME(3) DEFAULT NULL 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),
|
||||
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
|
||||
KEY idx_auth_refresh_expire (expires_at, revoked_at)
|
||||
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态';
|
||||
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态';
|
||||
|
||||
-- 初始管理员密码必须由部署脚本生成 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!
|
||||
-- The BCrypt hash is intentionally stored instead of plaintext.
|
||||
USE baiyue_auth;
|
||||
@@ -6,19 +6,18 @@ SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO auth_account (
|
||||
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
|
||||
) VALUES
|
||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0),
|
||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0)
|
||||
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
password_hash = VALUES(password_hash),
|
||||
display_name = VALUES(display_name),
|
||||
account_status = 'ACTIVE',
|
||||
failed_login_count = 0,
|
||||
locked_until = NULL,
|
||||
password_change_required = VALUES(password_change_required),
|
||||
default_tenant_id = VALUES(default_tenant_id),
|
||||
operator_tenant_id = VALUES(operator_tenant_id),
|
||||
default_membership_id = VALUES(default_membership_id),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
USE baiyue_auth;
|
||||
|
||||
SET @add_password_change_required = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
|
||||
AND column_name = 'password_change_required'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version');
|
||||
PREPARE phase1_customer_account_sql FROM @add_password_change_required;
|
||||
EXECUTE phase1_customer_account_sql;
|
||||
DEALLOCATE PREPARE phase1_customer_account_sql;
|
||||
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
|
||||
|
||||
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
|
||||
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"));
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class AuthSecurityConfigTest {
|
||||
@Test
|
||||
void permitsAnonymousImpersonationCodeExchange() {
|
||||
assertTrue(Arrays.asList(AuthSecurityConfig.PUBLIC_AUTH_ENDPOINTS)
|
||||
.contains("/api/auth/impersonations/exchange"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthTokenTtlConfigurationContractTest {
|
||||
@Test
|
||||
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||
String configuration = Files.readString(
|
||||
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(configuration)
|
||||
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DualTrackJwtConfigurationTest {
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
|
||||
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
|
||||
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
|
||||
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
|
||||
java.time.Duration.ofMinutes(15)))
|
||||
.withPropertyValues(
|
||||
"baiyue.security.overseas-jwt.enabled=false");
|
||||
|
||||
@Test
|
||||
void startsDomesticEncoderWithoutRsaConfiguration() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasBean("jwtEncoder");
|
||||
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
|
||||
assertThat(context).hasSingleBean(JwtEncoder.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jose.util.Base64URL;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class JwksControllerTest {
|
||||
@Test
|
||||
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
|
||||
RSAKey current = key("current", true);
|
||||
RSAKey previous = key("previous", false);
|
||||
String json = new JWKSet(List.of(current, previous)).toString(false);
|
||||
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
|
||||
"current", Duration.ofMinutes(15));
|
||||
|
||||
var body = new JwksController(properties).jwks().getBody();
|
||||
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
|
||||
|
||||
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
|
||||
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
|
||||
}
|
||||
|
||||
private RSAKey key(String kid, boolean privatePart) {
|
||||
RSAKey.Builder builder = new RSAKey.Builder(
|
||||
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
|
||||
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
|
||||
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||
@@ -23,6 +24,8 @@ import jakarta.annotation.Resource;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
|
||||
@DisplayName("账号管理服务")
|
||||
@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class})
|
||||
@@ -54,6 +57,25 @@ class AccountManagementServiceTest {
|
||||
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() {
|
||||
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
|
||||
assertEquals("DISABLED", account.getAccountStatus());
|
||||
@@ -134,6 +156,57 @@ class AccountManagementServiceTest {
|
||||
verify(repository).save(account);
|
||||
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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest {
|
||||
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")));
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||
|
||||
var result = service.register(new MerchantRegisterRequest(
|
||||
"registered_owner", "Password123", "注册人", "测试企业", "张三",
|
||||
@@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest {
|
||||
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")));
|
||||
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());
|
||||
|
||||
@@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest {
|
||||
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")));
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
|
||||
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,14 @@ class TokenSessionStoreTest {
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
|
||||
store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
|
||||
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||
|
||||
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30));
|
||||
verify(values).set(eq("baiyue:auth:access:jti-1"),
|
||||
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
|
||||
&& value.contains("\"userId\":42")
|
||||
&& value.contains("ADMIN") && value.contains("order:read")),
|
||||
eq(Duration.ofMinutes(30)));
|
||||
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
|
||||
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class BusinessAuditAspect {
|
||||
@Around("@annotation(audit)")
|
||||
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
|
||||
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;
|
||||
try { result = pjp.proceed(); return result; }
|
||||
catch (Throwable ex) { failure = ex; throw ex; }
|
||||
@@ -79,6 +79,12 @@ public class BusinessAuditAspect {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
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) {
|
||||
if (expression == null || expression.isBlank()) return null;
|
||||
Object value = evaluate(p, expression);
|
||||
|
||||
@@ -29,12 +29,14 @@ class BusinessAuditAspectTest {
|
||||
@Test
|
||||
void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable {
|
||||
AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L,
|
||||
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
|
||||
"platform.admin", "PLATFORM", "WEB", Set.of("platform:customer:edit")), 10001L);
|
||||
|
||||
assertThat(event.tenantId()).isEqualTo(10001L);
|
||||
assertThat(event.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
|
||||
@@ -46,7 +48,26 @@ class BusinessAuditAspectTest {
|
||||
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);
|
||||
@@ -57,7 +78,7 @@ class BusinessAuditAspectTest {
|
||||
new UsernamePasswordAuthenticationToken(user, null, List.of()));
|
||||
|
||||
TestOperations target = new TestOperations();
|
||||
Method method = TestOperations.class.getDeclaredMethod("update", long.class);
|
||||
Method method = TestOperations.class.getDeclaredMethod("update", AuthenticatedUser.class, long.class);
|
||||
MethodSignature signature = mock(MethodSignature.class);
|
||||
when(signature.getMethod()).thenReturn(method);
|
||||
when(signature.getParameterNames()).thenReturn(null);
|
||||
@@ -65,10 +86,15 @@ class BusinessAuditAspectTest {
|
||||
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
when(joinPoint.getTarget()).thenReturn(target);
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{requestedTenantId});
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
|
||||
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
|
||||
else when(joinPoint.proceed()).thenThrow(failure);
|
||||
|
||||
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
|
||||
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());
|
||||
@@ -76,8 +102,8 @@ class BusinessAuditAspectTest {
|
||||
}
|
||||
|
||||
private static final class TestOperations {
|
||||
@BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureBefore = false, captureAfter = false)
|
||||
public String update(long tenantId) {
|
||||
@BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureAfter = false)
|
||||
public String update(AuthenticatedUser user, long tenantId) {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
|
||||
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("******");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
|
||||
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
|
||||
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 新能力只有被多个模块稳定复用时才放入本模块。
|
||||
|
||||
@@ -14,12 +14,20 @@
|
||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 |
|
||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||
| 契约说明 | `package-info.java` |
|
||||
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||
| 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。
|
||||
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。
|
||||
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
|
||||
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
|
||||
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
|
||||
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
|
||||
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
|
||||
- 从父目录运行 `mvn -pl common-event-contract -am test`。
|
||||
|
||||
+33
-1
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
return write(sort(payload));
|
||||
}
|
||||
|
||||
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
|
||||
public static byte[] canonicalizeLegacy(JsonNode payload) {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
return write(sortLegacy(payload));
|
||||
}
|
||||
|
||||
private static byte[] write(JsonNode payload) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
||||
return OBJECT_MAPPER.writeValueAsBytes(payload);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||
}
|
||||
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
|
||||
node.forEach(child -> result.add(sort(child)));
|
||||
return result;
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static JsonNode sortLegacy(JsonNode node) {
|
||||
if (node.isObject()) {
|
||||
var names = new java.util.ArrayList<String>();
|
||||
node.fieldNames().forEachRemaining(names::add);
|
||||
names.sort(String::compareTo);
|
||||
var result = OBJECT_MAPPER.createObjectNode();
|
||||
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
|
||||
return result;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
var result = OBJECT_MAPPER.createArrayNode();
|
||||
node.forEach(child -> result.add(sortLegacy(child)));
|
||||
return result;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ public final class EventTypes {
|
||||
public static final String ORDER_APPROVED = "OrderApproved";
|
||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
||||
"FirstLegContainerShippingNodeRecorded";
|
||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
|
||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */
|
||||
public record FirstLegContainerShippingNodeRecordedV1(
|
||||
String queryId,
|
||||
String customerId,
|
||||
String oceanBookingId,
|
||||
String containerBookingId,
|
||||
String containerNo,
|
||||
String standardNode,
|
||||
int nodeSequence,
|
||||
Instant observedAt,
|
||||
Instant actualAt,
|
||||
String locationCode,
|
||||
String locationName,
|
||||
String providerCode,
|
||||
String providerEventId,
|
||||
String providerNodeCode,
|
||||
String sourceReference,
|
||||
String rawPayloadHash) {
|
||||
private static final Map<String, Integer> STANDARD_NODES = Map.of(
|
||||
"DEPARTED_WAREHOUSE", 10, "AT_ORIGIN_PORT", 20, "LOADED_ON_VESSEL", 30,
|
||||
"DEPARTED_ORIGIN_PORT", 40, "IN_TRANSIT", 50, "ARRIVED_DESTINATION_PORT", 60,
|
||||
"DISCHARGED_FROM_VESSEL", 70, "PICKUP_READY", 80, "PICKED_UP", 90,
|
||||
"EMPTY_RETURNED", 100);
|
||||
|
||||
public FirstLegContainerShippingNodeRecordedV1 {
|
||||
if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank()
|
||||
|| oceanBookingId == null || oceanBookingId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||
|| observedAt == null || actualAt == null) {
|
||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||
}
|
||||
if (providerCode == null || providerCode.isBlank() || providerEventId == null
|
||||
|| providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank()
|
||||
|| rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) {
|
||||
throw new IllegalArgumentException("船节点交接供应商审计字段不完整");
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV1(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
List<Order> orders) {
|
||||
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Item(
|
||||
String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
|
||||
public Item {
|
||||
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
}
|
||||
public Item(String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark) {
|
||||
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||
shipmentNo, shippingMark, null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键代发入仓单的托盘冻结快照。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
|
||||
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
|
||||
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||
int packageCount, int productQuantity,
|
||||
List<String> cartonMarks) {}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV2(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
List<Order> orders) {
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
String inboundCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Item(
|
||||
String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark,
|
||||
String customerRemark, String internalRemark, String warehouseRemark,
|
||||
String palletNo,
|
||||
/** 按实装箱数稳定随机冻结的头程箱号;数量与 packageCount 相等且均对应本明细 SKU。 */
|
||||
List<String> cartonMarks) {
|
||||
public Item {
|
||||
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||
}
|
||||
|
||||
public Item(String sourceInboundDetailId, String skuCode,
|
||||
String productNameZh, String productNameEn,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaTrackingNo,
|
||||
String shipmentNo, String shippingMark,
|
||||
String customerRemark, String internalRemark, String warehouseRemark) {
|
||||
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
|
||||
null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
|
||||
/** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */
|
||||
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||
int packageCount, int productQuantity,
|
||||
List<String> cartonMarks) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
||||
public final class IamSecurityEventsV1 {
|
||||
public static final int VERSION = 1;
|
||||
|
||||
private IamSecurityEventsV1() { }
|
||||
|
||||
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
|
||||
String status, long permissionVersion, Instant changedAt) { }
|
||||
|
||||
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
||||
|
||||
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
||||
long permissionVersion, String reason, Instant changedAt) { }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 海外仓柜级卸柜完成事实;国内订舱 ID 是稳定聚合与幂等标识,containerNo 用于核对物理柜号。
|
||||
* unloadingCompletedAt 是全柜 complete 时刻,不得使用签收拍照上传时刻代替。
|
||||
*/
|
||||
public record OverseasContainerUnloadedV1(
|
||||
String sourceContainerBookingId,
|
||||
String containerNo,
|
||||
Instant unloadingCompletedAt,
|
||||
Instant unloadingPhotoUploadedAt,
|
||||
Instant actualEmptyReportedAt
|
||||
) {
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外尾程按来源入仓单回传的暂扣状态事实。 */
|
||||
public record OverseasInboundOrderDetainedChangedV1(
|
||||
String sourceInboundId,
|
||||
String inboundCode,
|
||||
String lastlegOrderId,
|
||||
String lastlegOrderNo,
|
||||
boolean detained,
|
||||
String reason,
|
||||
String operatorId,
|
||||
String operatorName,
|
||||
Instant changedAt
|
||||
) {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class EventPayloadCanonicalizerTest {
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
|
||||
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
|
||||
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
|
||||
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
|
||||
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
|
||||
var negativeZero = json.readTree("{\"value\":-0.000}");
|
||||
var zero = json.readTree("{\"value\":0}");
|
||||
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
|
||||
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FirstLegContainerShippingNodeRecordedV1Test {
|
||||
@Test
|
||||
void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception {
|
||||
var payload = payload();
|
||||
var mapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
var restored = mapper.readValue(mapper.writeValueAsString(payload),
|
||||
FirstLegContainerShippingNodeRecordedV1.class);
|
||||
|
||||
assertThat(restored).isEqualTo(payload);
|
||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEstimatedOnlyOrIncompleteProviderFacts() {
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), null, null, null,
|
||||
"ZIM", "evt-1", "Carrier Release", "ZIM:evt-1", "a".repeat(64)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("实际时间");
|
||||
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", "ZCSU7881970", "ARRIVED", 60,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
null, null, "ZIM", "evt-2", "ARRIVED", "ZIM:evt-2", "b".repeat(64)))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
private static FirstLegContainerShippingNodeRecordedV1 payload() {
|
||||
return new FirstLegContainerShippingNodeRecordedV1(
|
||||
"2088000000000000001", "2088000000000000002", "2088000000000000004",
|
||||
"2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
"USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE",
|
||||
"ZIM:evt-1", "a".repeat(64));
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
|
||||
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
|
||||
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||
new BigDecimal("0.200"), List.of(item));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
|
||||
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||
"3", "海外仓", List.of(order));
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
assertThat(restored).isEqualTo(payload);
|
||||
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||
String json = """
|
||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
||||
""";
|
||||
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV2Test {
|
||||
@Test
|
||||
void carriesTheThreeItemRemarks() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||
"客户备注", "内部备注", "仓库备注");
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
String json = new ObjectMapper().writeValueAsString(payload);
|
||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||
.isEqualTo("仓库备注");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
|
||||
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
|
||||
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
|
||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesFrozenDestinationType() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsEachOrdinaryCartonNumberToItsSku() {
|
||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
|
||||
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasContainerUnloadedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
|
||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@
|
||||
| `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` |
|
||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||
| 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` |
|
||||
| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
|
||||
- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。
|
||||
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
|
||||
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
|
||||
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
|
||||
- 从父目录运行 `mvn -pl common-openapi -am test`。
|
||||
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
|
||||
|
||||
+8
@@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration {
|
||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||
return GroupedOpenApi.builder().group("pda")
|
||||
.displayName("PDA APIs / PDA 作业接口")
|
||||
.pathsToMatch("/api/pda/**")
|
||||
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
private Parameter header(String name, String description, boolean required) {
|
||||
return new HeaderParameter().name(name).description(description).required(required)
|
||||
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.common.openapi;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaOpenApiGroupContractTest {
|
||||
@Test
|
||||
void pdaGroupOnlyMatchesDeviceApis() {
|
||||
BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration();
|
||||
var group = configuration.pdaApi(configuration.correlationHeaderCustomizer());
|
||||
|
||||
assertThat(group.getGroup()).isEqualTo("pda");
|
||||
assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**");
|
||||
assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal"));
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,20 @@
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` |
|
||||
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
|
||||
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
|
||||
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
|
||||
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
|
||||
| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` |
|
||||
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` |
|
||||
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
|
||||
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
|
||||
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
|
||||
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
|
||||
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
|
||||
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
|
||||
- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。
|
||||
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
|
||||
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
|
||||
- 从父目录运行 `mvn -pl common-security -am test`。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Server-side access session and its current authorization snapshot. */
|
||||
public record AccessTokenSession(int version, Long userId, Set<String> roles,
|
||||
Set<String> permissions) {
|
||||
public AccessTokenSession {
|
||||
if (version != 1 || userId == null || userId <= 0) {
|
||||
throw new IllegalArgumentException("Invalid access token session");
|
||||
}
|
||||
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AccessTokenSessionValidator {
|
||||
void validate(Jwt jwt);
|
||||
AccessTokenSession validate(Jwt jwt);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,37 @@ import java.util.Set;
|
||||
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
|
||||
*/
|
||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType,
|
||||
Set<String> permissions) implements Principal {
|
||||
String username, String displayName, String appCode, String clientType,
|
||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||
Long actorMembershipId, boolean passwordChangeRequired,
|
||||
Long overseasWarehouseId) implements Principal {
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, false, null, null, false, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions,
|
||||
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||
boolean passwordChangeRequired) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String displayName, String appCode, String clientType,
|
||||
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||
Long actorMembershipId, boolean passwordChangeRequired) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, displayName, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null);
|
||||
}
|
||||
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType, Set<String> permissions,
|
||||
boolean impersonated, Long actorUserId, Long actorMembershipId,
|
||||
boolean passwordChangeRequired, Long overseasWarehouseId) {
|
||||
this(userId, tenantId, operatorTenantId, membershipId, username, null, appCode, clientType,
|
||||
permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired,
|
||||
overseasWarehouseId);
|
||||
}
|
||||
public AuthenticatedUser {
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
}
|
||||
@@ -18,4 +47,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-5
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||
*/
|
||||
@AutoConfiguration(
|
||||
after = HmacJwtDecoderAutoConfiguration.class,
|
||||
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
|
||||
before = SecurityAutoConfiguration.class)
|
||||
@EnableMethodSecurity
|
||||
public class BaiyueSecurityAutoConfiguration {
|
||||
@@ -47,10 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
|
||||
@ConditionalOnMissingBean
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix));
|
||||
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
||||
ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
||||
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||
AccessTokenSessionValidator validator;
|
||||
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||
else {
|
||||
StringRedisTemplate template=redis.getIfAvailable();
|
||||
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
|
||||
}
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
|
||||
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
@Override public AccessTokenSession validate(Jwt jwt) {
|
||||
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
|
||||
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
|
||||
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
|
||||
}
|
||||
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
|
||||
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
|
||||
*/
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-secret")
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
|
||||
public class HmacJwtDecoderAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(JwtDecoder.class)
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
|
||||
public class JwksJwtDecoderAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer,
|
||||
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
JwtValidators.createDefaultWithIssuer(issuer),
|
||||
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(audience))));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -27,7 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint) {
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { });
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null);
|
||||
}
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
@@ -55,8 +55,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String tokenValue = header.substring(BEARER.length()).trim();
|
||||
try {
|
||||
Jwt jwt = jwtDecoder.decode(tokenValue);
|
||||
sessionValidator.validate(jwt);
|
||||
Authentication authentication = claimsMapper.map(jwt);
|
||||
AccessTokenSession session = sessionValidator.validate(jwt);
|
||||
Authentication authentication = claimsMapper.map(jwt, session);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (JwtException | IllegalArgumentException exception) {
|
||||
|
||||
@@ -13,28 +13,39 @@ import java.util.Set;
|
||||
*/
|
||||
public class JwtClaimsMapper {
|
||||
public BaiyueJwtAuthenticationToken map(Jwt jwt) {
|
||||
Set<String> permissions = new LinkedHashSet<>();
|
||||
addClaimValues(permissions, jwt.getClaims().get("authorities"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("permissions"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("scope"));
|
||||
addRoles(permissions, jwt.getClaims().get("roles"));
|
||||
return map(jwt, null);
|
||||
}
|
||||
|
||||
String appCode = jwt.getClaimAsString("app_code");
|
||||
public BaiyueJwtAuthenticationToken map(Jwt jwt, AccessTokenSession session) {
|
||||
Set<String> permissions = new LinkedHashSet<>();
|
||||
if (session != null) {
|
||||
permissions.addAll(session.permissions());
|
||||
addRoles(permissions, session.roles());
|
||||
}
|
||||
|
||||
String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code"));
|
||||
if (appCode != null && !appCode.isBlank()) {
|
||||
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String username = firstNonBlank(jwt.getClaimAsString("username"),
|
||||
jwt.getClaimAsString("preferred_username"), jwt.getSubject());
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
Long userId = longClaim(jwt, "userId");
|
||||
if (userId == null) userId = longClaim(jwt, "user_id");
|
||||
AuthenticatedUser user = new AuthenticatedUser(
|
||||
userId == null ? longValue(jwt.getSubject()) : userId,
|
||||
longClaim(jwt, "tenant_id"),
|
||||
longClaimEither(jwt, "tenantId", "tenant_id"),
|
||||
longClaim(jwt, "operator_tenant_id"),
|
||||
longClaim(jwt, "membership_id"),
|
||||
username,
|
||||
jwt.getClaimAsString("app_code"),
|
||||
firstNonBlankNullable(jwt.getClaimAsString("displayName"), jwt.getClaimAsString("display_name")),
|
||||
appCode,
|
||||
jwt.getClaimAsString("client_type"),
|
||||
permissions);
|
||||
permissions,
|
||||
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
|
||||
longClaim(jwt, "actor_user_id"),
|
||||
longClaim(jwt, "actor_membership_id"),
|
||||
Boolean.TRUE.equals(jwt.getClaim("password_change_required")),
|
||||
longClaim(jwt, "overseas_warehouse_id"));
|
||||
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
||||
.map(SimpleGrantedAuthority::new).toList();
|
||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||
@@ -68,10 +79,18 @@ public class JwtClaimsMapper {
|
||||
if (value instanceof String text) return longValue(text);
|
||||
return null;
|
||||
}
|
||||
private Long longClaimEither(Jwt jwt, String first, String second) {
|
||||
Long value = longClaim(jwt, first);
|
||||
return value == null ? longClaim(jwt, second) : value;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value;
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private String firstNonBlankNullable(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value.trim();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+28
-2
@@ -2,6 +2,7 @@ package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。
|
||||
@@ -9,24 +10,49 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
||||
public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final String keyPrefix;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
|
||||
this(redisTemplate, keyPrefix, new ObjectMapper());
|
||||
}
|
||||
|
||||
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix,
|
||||
ObjectMapper objectMapper) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Jwt jwt) {
|
||||
public AccessTokenSession validate(Jwt jwt) {
|
||||
String jwtId = jwt.getId();
|
||||
if (jwtId == null || jwtId.isBlank()) {
|
||||
throw new IllegalArgumentException("Access token has no jti");
|
||||
}
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) {
|
||||
String value = redisTemplate.opsForValue().get(RedisTokenKeys.access(keyPrefix, jwtId));
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Access token session is revoked or expired");
|
||||
}
|
||||
AccessTokenSession session = objectMapper.readValue(value, AccessTokenSession.class);
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
if (userId == null || !userId.equals(session.userId())) {
|
||||
throw new IllegalArgumentException("Access token session identity mismatch");
|
||||
}
|
||||
return session;
|
||||
} catch (org.springframework.dao.DataAccessException exception) {
|
||||
throw new IllegalArgumentException("Token session store is unavailable", exception);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalArgumentException("Invalid access token session", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Long longClaim(Jwt jwt, String name) {
|
||||
Object value = jwt.getClaims().get(name);
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
if (value instanceof String text) try { return Long.valueOf(text); } catch (NumberFormatException ignored) { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
|
||||
|
||||
@@ -27,13 +27,31 @@ class JwtClaimsMapperTest {
|
||||
.claim("roles", List.of("ADMIN"))
|
||||
.build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt);
|
||||
AccessTokenSession session = new AccessTokenSession(1, 42L,
|
||||
java.util.Set.of("ADMIN"), java.util.Set.of("order:read", "order:create"));
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, session);
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(42L);
|
||||
assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L);
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresAuthorizationClaimsInJwt() {
|
||||
Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").subject("42")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM")
|
||||
.claim("permissions", List.of("platform:forged"))
|
||||
.claim("roles", List.of("FORGED")).build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt,
|
||||
new AccessTokenSession(1, 42L, java.util.Set.of(), java.util.Set.of("platform:real")));
|
||||
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("platform:real", "APP_PLATFORM")
|
||||
.doesNotContain("platform:forged", "ROLE_FORGED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToNumericSubjectForUserId() {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
@@ -49,4 +67,28 @@ class JwtClaimsMapperTest {
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(99L);
|
||||
assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsImpersonationAndForcedPasswordClaims() {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
.header("alg", "none")
|
||||
.subject("99")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("tenant_id", 7L)
|
||||
.claim("app_code", "MERCHANT")
|
||||
.claim("impersonated", true)
|
||||
.claim("actor_user_id", 42L)
|
||||
.claim("actor_membership_id", 43L)
|
||||
.claim("password_change_required", true)
|
||||
.claim("overseas_warehouse_id", 900L)
|
||||
.build();
|
||||
|
||||
AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal();
|
||||
assertThat(user.impersonated()).isTrue();
|
||||
assertThat(user.actorUserId()).isEqualTo(42L);
|
||||
assertThat(user.actorMembershipId()).isEqualTo(43L);
|
||||
assertThat(user.passwordChangeRequired()).isTrue();
|
||||
assertThat(user.overseasWarehouseId()).isEqualTo(900L);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -18,7 +19,10 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void acceptsActiveSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}");
|
||||
|
||||
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1"))).doesNotThrowAnyException();
|
||||
@@ -27,7 +31,9 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void rejectsRevokedSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1")).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
@@ -38,7 +44,9 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void failsClosedWhenRedisIsUnavailable() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1"))
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenThrow(new RedisConnectionFailureException("offline"));
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
@@ -47,9 +55,37 @@ class RedisAccessTokenSessionValidatorTest {
|
||||
.hasMessageContaining("unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSessionForAnotherUser() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1"))
|
||||
.thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}");
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("identity mismatch");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedSessionJson() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json");
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid access token session");
|
||||
}
|
||||
|
||||
private Jwt jwt(String jwtId) {
|
||||
return Jwt.withTokenValue("token").header("alg", "none")
|
||||
.subject("1").claim("jti", jwtId).issuedAt(Instant.now())
|
||||
.claim("user_id", 1L)
|
||||
.expiresAt(Instant.now().plusSeconds(60)).build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|---|---|
|
||||
| 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` |
|
||||
| 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` |
|
||||
| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID |
|
||||
| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 |
|
||||
| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 |
|
||||
| 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 |
|
||||
| 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 |
|
||||
| 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` |
|
||||
@@ -22,6 +25,9 @@
|
||||
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
|
||||
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
|
||||
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
|
||||
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
|
||||
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
|
||||
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
|
||||
- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。
|
||||
- 从父目录运行 `mvn -pl common-web -am test`。
|
||||
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-core</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -9,8 +9,11 @@ import org.slf4j.MDC;
|
||||
public final class CorrelationContext {
|
||||
public static final String REQUEST_ID = "requestId";
|
||||
public static final String TRACE_ID = "traceId";
|
||||
public static final String SPAN_ID = "spanId";
|
||||
public static final String TRACE_PARENT = "traceparent";
|
||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
||||
public static final String TRACE_PARENT_HEADER = TraceParent.HEADER;
|
||||
|
||||
private CorrelationContext() {}
|
||||
|
||||
@@ -22,6 +25,14 @@ public final class CorrelationContext {
|
||||
return value(request, TRACE_ID);
|
||||
}
|
||||
|
||||
public static String spanId(HttpServletRequest request) {
|
||||
return value(request, SPAN_ID);
|
||||
}
|
||||
|
||||
public static String traceParent(HttpServletRequest request) {
|
||||
return value(request, TRACE_PARENT);
|
||||
}
|
||||
|
||||
private static String value(HttpServletRequest request, String key) {
|
||||
Object attribute = request.getAttribute(key);
|
||||
return attribute == null ? MDC.get(key) : attribute.toString();
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Enumeration;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false);
|
||||
String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true);
|
||||
String requestId = requestId(request);
|
||||
TraceParent traceParent = traceParent(request);
|
||||
String traceId = traceParent.traceId();
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, requestId);
|
||||
request.setAttribute(CorrelationContext.TRACE_ID, traceId);
|
||||
request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId());
|
||||
request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value());
|
||||
response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId);
|
||||
response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId);
|
||||
response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value());
|
||||
|
||||
try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId);
|
||||
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) {
|
||||
MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId);
|
||||
MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
private String acceptedOrGenerated(String candidate, boolean compact) {
|
||||
if (candidate != null && SAFE_ID.matcher(candidate).matches()) {
|
||||
return candidate;
|
||||
private String requestId(HttpServletRequest request) {
|
||||
HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128);
|
||||
if (header.valid() && SAFE_ID.matcher(header.value()).matches()) {
|
||||
return header.value();
|
||||
}
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private TraceParent traceParent(HttpServletRequest request) {
|
||||
HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER,
|
||||
TraceParent.HEADER_LENGTH);
|
||||
if (standard.present()) {
|
||||
if (standard.valid()) {
|
||||
return TraceParent.parse(standard.value()).map(TraceParent::childOf)
|
||||
.orElseGet(() -> TraceParent.newRoot(null));
|
||||
}
|
||||
return TraceParent.newRoot(null);
|
||||
}
|
||||
|
||||
HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32);
|
||||
return TraceParent.newRoot(legacy.valid() ? legacy.value() : null);
|
||||
}
|
||||
|
||||
private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) {
|
||||
Enumeration<String> values = request.getHeaders(name);
|
||||
if (values == null || !values.hasMoreElements()) {
|
||||
return HeaderValue.missing();
|
||||
}
|
||||
String value = values.nextElement();
|
||||
boolean duplicate = values.hasMoreElements();
|
||||
return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value);
|
||||
}
|
||||
|
||||
private record HeaderValue(boolean present, boolean valid, String value) {
|
||||
private static HeaderValue missing() {
|
||||
return new HeaderValue(false, false, null);
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
return compact ? id.replace("-", "") : id;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user